Compare commits

..

62 Commits

Author SHA1 Message Date
Jörg Lohrer b2cbbb6390 docs: ci-setup-guide + status/handoff für ci-phase aktualisiert
docs/github-ci-setup.md dokumentiert:
  - forgejo → github push-mirror
  - die 4 github-repository-secrets (bunker-url, author-pubkey-hex,
    bootstrap-relay, client-secret-hex) — letzteres identisch mit
    .env.local für stabile amber-app-identität
  - wie man sie rotiert
  - migrations-pfad weg von github (woodpecker, cron)

status + handoff reflektieren: pipeline live, alle 18 posts publiziert,
91 bilder auf blossom, ci-setup steht, cutover als nächster schritt
möglich.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 07:27:48 +02:00
Jörg Lohrer 2f7f991bc6 publish(task 22): github-actions-workflow für auto-publish
.github/workflows/publish.yml triggert bei:
  - push auf main mit änderungen in content/posts/**
  - manuellem workflow_dispatch (optional mit force_all=true)

ablauf:
  1. deno 2.x setup
  2. pre-flight check (bunker, kind:10002, kind:10063)
  3. publish (diff-modus per default, force-all bei manuellem trigger)
  4. log-artefakt (publish-*.json, 30 tage retention)

benötigt 4 repo-secrets im github-ui:
  - BUNKER_URL
  - AUTHOR_PUBKEY_HEX
  - BOOTSTRAP_RELAY
  - CLIENT_SECRET_HEX (stabile client-identität für amber-permissions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 06:52:08 +02:00
Jörg Lohrer 0c6fdd15c3 publish(task 21): date-string-coercion + force-all migration erfolgreich
validatePost akzeptiert jetzt auch string-dates im YYYY-MM-DD- oder
ISO-8601-format und coerced sie in ein Date-objekt in-place. vorher
schlug die validation für 13 von 18 altposts fehl, weil deren yaml
`date: "2023-02-26"` quoted war (hugo-konvention) und der yaml-parser
strings statt Date-instanzen liefert.

migration durchgelaufen (log in docs/publish-logs/): 18/18 success,
91 bilder auf beiden blossom-servern, 5 write-relays — bis auf
relay.damus.io, der bei 6 posts nicht auf OK antwortet (üblich bei
damus, rate-limiting). alle 7 multi-relay-posts haben weiter mindestens
4 acks (über MIN_RELAY_ACKS=2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 06:48:27 +02:00
Jörg Lohrer db61149924 publish(tasks 19+20): signer-stabilisierung für wiederholte runs
probleme auf der realen amber-infrastruktur behoben:

1. ohne festen CLIENT_SECRET_HEX erzeugt applesauce bei jedem lauf einen
   neuen client-pubkey. amber bindet permissions pro client-pubkey, also
   sah jeder lauf wie eine neue unberechtigte app aus und bekam
   "no permission" als auto-antwort.
   → CLIENT_SECRET_HEX in config + cli, SimpleSigner.fromKey durchgereicht.

2. applesauce wirft bei "already connected"/"no permission" unhandled
   rejections, weil response-promises asynchron reagieren.
   → globaler unhandledrejection-handler, der diese benannten fehler
   schluckt; connect() im try/catch mit open+force als fallback.

3. timeout auf bunker connect auf 60s erhöht (amber-pairing kann
   menschliches tap dauern beim ersten mal).

einzel-post-publish live verifiziert:
- offenheit-das-wesentliche als kind:30023 publiziert
- alle 5 write-relays haben bestätigt
- bild auf beide blossom-server hochgeladen
- SPA rendert das bild von blossom.edufeed.org

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 06:42:28 +02:00
Jörg Lohrer 18d9dad56e publish(task 18): cli-entrypoint mit subcommand-dispatch
src/cli.ts dispatcht via @std/cli/parse-args:
  - publish [--force-all | --post <slug> | --dry-run]
  - check
  - validate-post <path>

cmdPublish orchestriert:
  1. config laden, signer initialisieren, outbox + blossom-server laden
  2. post-dirs resolven (diff/force-all/single per slug)
  3. dry-run → liste printen, exit 0
  4. für jeden post: processPost aufrufen, logger aktualisieren
  5. am ende: logs/publish-<iso>.json, exit-code je nach fehlern

resolvePostDirs schaltet zwischen den drei modi um und findet bei
--post <slug> den passenden ordner über allPostDirs + findBySlug.

smoke-tests aus dem plan (usage → exit 2, validate-post → ✓) gehen
durch. alle 57 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:45:54 +02:00
Jörg Lohrer 4d9af00a97 publish(task 17): validate-post-subcommand
validatePostFile(path) liest post-datei, parst frontmatter, ruft
validatePost aus core/validation. liefert { ok, slug?, error? }.
offline-befehl ohne netz-zugriff. 3 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:40:29 +02:00
Jörg Lohrer f31e586e85 publish(task 16): check-subcommand (pre-flight-validation)
runCheck(config) prüft vor jedem publish-run:
  - bunker-signer erreichbar und pubkey matcht AUTHOR_PUBKEY_HEX
  - kind:10002 outbox-liste enthält mindestens 1 write-relay
  - kind:10063 blossom-liste nicht leer, alle server antworten auf
    HEAD / (405 wird toleriert — blossom-server erlauben oft kein HEAD)

liefert { ok, issues }. printCheckResult() druckt ✓ oder ✗ + liste.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:39:51 +02:00
Jörg Lohrer 68ea912fad publish(task 15): processPost — kern-pipeline pro post (tdd)
processPost(args) orchestriert pro post:
  1. frontmatter parsen + validieren
  2. draft → skipped-draft
  3. bilder sammeln + sequentiell zu blossom hochladen (mapping
     dateiname → primary-url)
  4. body mit rewriteImageUrls anpassen, coverUrl via resolveCoverUrl
  5. kind:30023 event bauen via buildKind30023
  6. checkExisting → action = new|update
  7. signieren via nip-46
  8. publishToRelays, prüfen ob minRelayAcks erreicht

alle externen abhängigkeiten (readPostFile, collectImages, upload,
sign, publish, checkExisting) via PostDeps-interface eingezogen
— einfach mockbar. fehler aller art landen als { status: failed,
error: msg }. 6 tests grün.

follow-up (nicht teil von task 15): license-tag und imeta-tags aus
images[]-frontmatter sind noch nicht im event. kommt in eigenem
folge-task, basierend auf der metadata-convention-spec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:37:05 +02:00
Jörg Lohrer db85061287 publish(task 14): structured json logger
createLogger(opts) sammelt postSuccess/postFailed/postSkippedDraft-
events, druckt menschenlesbare zeilen (✓/✗/-), liefert am ende ein
RunLog mit allen einträgen plus start/end-timestamps. writeJson()
schreibt die komplette summary als json für archivierung/ci-artifact.
2 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:35:41 +02:00
Jörg Lohrer b6196f1052 publish(task 13): git-diff change-detection für post-ordner
filterPostDirs(lines, contentRoot) extrahiert post-verzeichnisse aus
git-diff-ausgabe (index.md-matches + asset-changes), ignoriert
_drafts/. contentRoot ist parameter (blaupausen-tauglich für nicht-
hugo-layouts). changedPostDirs(from, to, contentRoot, runner?) ruft
"git diff --name-only A..B" via dependency-injected runner. Plus
allPostDirs() für den --force-all-modus. 4 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:33:56 +02:00
Jörg Lohrer 02a955c46f publish(task 12): blossom-upload mit multi-server, bud-01 auth
uploadBlob(args) berechnet sha256, baut kind:24242-auth-event via
signer, schickt es base64-kodiert im authorization-header an PUT
/upload aller servers parallel. erfolg: report mit ok/failed-listen
und primaryUrl (erster erfolgreicher server). wirft wenn alle ablehnen.
BlossomClient via dependency-injection für tests.
TS-casts für Uint8Array→BufferSource/BodyInit (deno-strict). 3 tests
grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:32:58 +02:00
Jörg Lohrer 8eebd29266 publish(task 11): image-collector (ignoriert hugo-derivate)
collectImages(postDir) scannt ordner nach png/jpg/jpeg/gif/webp/svg,
ignoriert hugo-resize-derivate (*_hu_<hex>.*), liest bytes und gibt
sortierte ImageFile[]-liste zurück. mimeFromExt() mapped extension
auf mime-type. deno.jsonc test-task um --allow-write erweitert (Deno.
makeTempDir+writeFile in tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:31:32 +02:00
Jörg Lohrer 05ba4e4ef9 publish(task 10): nip-46 bunker-signer-wrapper mit timeout
createBunkerSigner(bunkerUrl) nutzt NostrConnectSigner.fromBunkerURI
aus applesauce-signers 2.x (früher als Nip46Signer im plan bezeichnet;
klassenname hat sich geändert). subscription- und publishMethod werden
global am class-constructor an einen shared RelayPool gekoppelt.
getPublicKey und signEvent bekommen je 30s-timeout mit sauberem
clearTimeout via withTimeout-helper. signer.ts ist vollständig, aber
ohne eigene unit-tests — integration folgt über check-subcommand
(task 16).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:30:15 +02:00
Jörg Lohrer 0a858371bf publish(task 9): blossom-server-liste-loader (kind:10063)
parseBlossomServers(ev) extrahiert "server"-tag-urls, normalisiert
trailing-slash. loadBlossomServers(bootstrapRelay, pubkey) fragt
kind:10063 via applesauce-relay. 3 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:28:44 +02:00
Jörg Lohrer 1ec48ad1a9 publish(task 8): outbox-relay-loader (kind:10002 parser + fetcher)
parseOutbox(ev) interpretiert nip-65 r-tags: ohne marker → read+write,
"read"/"write"-marker → nur jeweiliges set. ignoriert fremde tag-namen.
loadOutbox(bootstrapRelay, pubkey) fragt kind:10002-event via
applesauce-relay 2.x und parst das ergebnis. 3 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:27:47 +02:00
Jörg Lohrer ebe73cbf46 publish(task 7): relay-pool-wrapper (publish + checkExisting)
publishToRelays(urls, ev, opts) publisht signiertes event parallel zu
allen relays, mit retries + exponential backoff + timeout pro versuch.
retour: { ok: string[], failed: string[] }. default-pool via
applesauce-relay 2.x (new RelayPool()); publishFn via dependency-
injection für tests. checkExisting(slug, pubkey, urls) fragt je relay
nach kind:30023 mit #d-filter ab — true wenn irgendeiner matcht.
timer-leaks vermieden per clearTimeout in publishOne + im mock-test.
3 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:27:12 +02:00
Jörg Lohrer e4518fbf69 publish(task 6): kind:30023 event-builder mit tag-mapping
buildKind30023(args) baut unsigniertes kind:30023-event aus frontmatter
+ rewritten-body + cover-url. erzeugt pflicht-tags (d, title,
published_at) und bedingt optionale (summary aus description, image
aus coverUrl, t-tags aus tags[], client aus clientTag). plus
additionalTags-parameter für spätere task 15: license-tag und
imeta-tags (mit blossom-sha256) werden dort nach dem upload angehängt.
4 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:25:10 +02:00
Jörg Lohrer a6c5bd26e7 publish(task 5): markdown bild-url-rewriter (mapping-basiert, =WxH-strip)
rewriteImageUrls(md, mapping) ersetzt alle ![alt](filename)- und
[![alt](filename)](link)-konstrukte per dateinamen-lookup durch
blossom-urls aus dem mapping. absolute urls bleiben unverändert,
unbekannte dateinamen bleiben stehen (kein strip). =WxH-größenhinweise
werden entfernt. URL-dekodierung für leerzeichen-dateinamen (z.b.
'03-config generieren.png' im moodle-post). resolveCoverUrl()-helper
für den cover-lookup. 7 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:24:03 +02:00
Jörg Lohrer bc2679aeba publish(task 4): slug- und post-validation
validateSlug prüft regex ^[a-z0-9][a-z0-9-]*$ (lowercase, digits,
hyphen; kein führender hyphen). validatePost checkt title, slug
und date (muss Date-instanz mit gültigem timestamp sein). 7 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:23:18 +02:00
Jörg Lohrer 178016f0f4 publish(task 3): frontmatter-parser mit yaml + body-split
parseFrontmatter(md) trennt yaml-frontmatter vom markdown-body via
regex, parst yaml mit @std/yaml. Frontmatter-interface enthält
nostr-publish-konvention: optionale images-liste mit file/role/alt/
license/authors plus Author-interface (name/url/orcid). 4 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:22:41 +02:00
Jörg Lohrer 1e4359aab6 publish(task 2): config-loader mit env-validation
loadConfig() liest 3 pflicht-keys (BUNKER_URL, AUTHOR_PUBKEY_HEX,
BOOTSTRAP_RELAY) und 3 optionale mit defaults (CONTENT_ROOT,
CLIENT_TAG=leer, MIN_RELAY_ACKS=2). pubkey-hex-format validiert
(64 lowercase hex), MIN_RELAY_ACKS als positive integer. 6 tests, alle
grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:21:39 +02:00
Jörg Lohrer 6b6502a22c publish(task 1): deno-grundgerüst (deno.jsonc, .env.example, readme)
deno 2.x projekt mit jsr/npm-imports für @std, nostr-tools, applesauce-signers,
applesauce-relay und rxjs. env-handling: primär ../.env.local (projekt-lokal),
alternativ publish/.env für fremd-repos (template in .env.example). tasks für
publish, check, validate-post, test, fmt, lint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:20:19 +02:00
Jörg Lohrer 32fe856232 docs: status + handoff für fortsetzung morgen aktualisiert
- aktueller stand: content-migration fertig, pipeline-plan geschrieben
- nächster schritt klar benannt: task 1 aus publish-pipeline-plan
- env-vorarbeiten dokumentiert (alle keys in .env.local)
- offene UNKNOWN-liste für spätere recherche

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:17:57 +02:00
Jörg Lohrer 931ef9f03f docs: publish-pipeline-vorbereitung + bild-metadaten-konvention
- spec publish-pipeline aktualisiert: blossom-only (rsync-legacy-pfad raus)
- plan publish-pipeline (24 tasks in 12 phasen), blaupausen-tauglich:
  alle projekt-konstanten via env (BUNKER_URL, AUTHOR_PUBKEY_HEX,
  BOOTSTRAP_RELAY, CONTENT_ROOT, CLIENT_TAG, MIN_RELAY_ACKS)
- spec bild-metadaten-konvention (phase 1, yaml-basiert)
- wiki-entwurf deutsch + englisch: inline-markdown-konvention zur
  bildattribution mit mapping zu nip-92 imeta-tags. konvention für
  menschen, parser passt sich an; bei ambiguität eskalation zur
  redaktion statt stillem raten
- redaktions-checkliste für bild-durchgang (abgearbeitet, bleibt
  zum abgleich bei späteren migrationen)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:14:02 +02:00
Jörg Lohrer c023b59769 content: strukturierte bild-metadaten für alle 18 posts
pro post ein images:-block im frontmatter mit file, role, alt, license,
authors — als vorbereitung für publish-pipeline (imeta-tags nip-92).
91 bilder insgesamt. fünf UNKNOWN-einträge im vr-post zur späteren
recherche markiert (wikipedia-screenshot, sketchfab-fotograf,
ready-player-me, eyemeasure-app).

bei erlebnispadagogik-post: tote amazon-hotlinks entfernt, literatur-
referenzen in saubere textliste umgebaut.

redaktionell geprüft; CC0 für eigene fotos und screenshots,
CC BY-SA 3.0 DE für saemann-midjourney-collage, CC BY 4.0 für
dezentrale-oep-oer-gemeinschaftsbeitrag, CC BY-NC-SA 3.0 für
raupen-flickr-bild, CC BY-NC 4.0 für sketchfab-kirche.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:13:47 +02:00
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
117 changed files with 10642 additions and 252 deletions

57
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: Publish Nostr Events
on:
push:
branches: [main]
paths: ['content/posts/**']
workflow_dispatch:
inputs:
force_all:
description: 'Publish all posts (--force-all)'
type: boolean
default: false
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Pre-Flight Check
working-directory: ./publish
env:
BUNKER_URL: ${{ secrets.BUNKER_URL }}
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
CLIENT_SECRET_HEX: ${{ secrets.CLIENT_SECRET_HEX }}
run: |
deno run --allow-env --allow-read --allow-net src/cli.ts check
- name: Publish
working-directory: ./publish
env:
BUNKER_URL: ${{ secrets.BUNKER_URL }}
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
CLIENT_SECRET_HEX: ${{ secrets.CLIENT_SECRET_HEX }}
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
run: |
if [ "${{ github.event.inputs.force_all }}" = "true" ]; then
deno run --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish --force-all
else
deno run --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: publish-log
path: ./publish/logs/publish-*.json
retention-days: 30

5
.gitignore vendored
View File

@ -1 +1,4 @@
**/.DS_Store
**/.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

@ -11,6 +11,14 @@ author: Jörg Lohrer
slug: "premium-freemium-mium-mium-mium"
lang: de
dir: ltr
images:
- file: my-very-hungry-caterpillar.jpg
role: cover
alt: "Kleine Raupe aus Papier gefaltet, Anspielung auf das Kinderbuch 'Die kleine Raupe Nimmersatt'"
license: "https://creativecommons.org/licenses/by-nc-sa/3.0/"
authors:
- name: "Relly Annett-Baker"
source_url: "https://www.flickr.com/photos/fizzkitten/4454153264/"
---

View File

@ -12,9 +12,7 @@ dir: ltr
# Erlebnispädagogik im Handbuch Jugend Evangelische Perspektiven
Das
[![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3847400746&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)](http://ci-muenster.de/bookshop/artikel/buecher/Bildung-im-Kindes-und-Jugendalter/A40097_Handbuch_Jugend_2013.php)
**Handbuch Jugend Evangelische Perspektiven**, welches 2013 erschienen ist, hat auf Seite 343-345 folgenden Artikel von mir zur Erlebnispädagogik abgedruckt.
Das [**Handbuch Jugend Evangelische Perspektiven**](http://ci-muenster.de/bookshop/artikel/buecher/Bildung-im-Kindes-und-Jugendalter/A40097_Handbuch_Jugend_2013.php), welches 2013 erschienen ist, hat auf Seite 343-345 folgenden Artikel von mir zur Erlebnispädagogik abgedruckt.
[CC BY](http://creativecommons.org/licenses/by/2.0/de/) Jörg Lohrer
## Erlebnispädagogik
@ -44,14 +42,10 @@ Die Wirkungsforschung erlebnispädagogischer Lernarrangements untersucht deren S
Im Kontext evangelischer Jugendbildung stellt sich bei der Verwendung erlebnispädagogischer Methoden immer auch die Frage nach einer Verknüpfung von christlichen Inhalten mit der handlungsorientierten Pädagogik. Die Assoziation von Natur und Schöpfung ist beispielsweise ebenso naheliegend wie die Reflexion von religiösen Erfahrungen in erlebnispädagogischen Lernsettings. Inwiefern sich dabei Unverfügbares inszenieren lässt, bleibt dabei ebenso eine Herausforderung, wie bei der oben beschriebenen Wirkungsforschung. In den vergangenen Jahren haben sich einige Konzepte zur Erprobung erlebnispädagogischer Methoden im christlichen Kontext entwickelt, die zunehmend auch theologisch und religionsdidaktisch reflektiert werden. Eine überregionale Organisation dieser Einzelinitiativen auf Bundesebene wäre der nächste Schritt hin zu einem evangelischen Profil erlebnispädagogischer Jugendbildungsarbeit.
### Literatur
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3866870493&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)
Großer, Achim/Oberländer, Rainer/Lohrer, Jörg/Wiedmayer, Jörg (2005/2011): Sinn gesucht Gott erfahren. Erlebnispädagogik im christlichen Kontext (Band 1 (2005)/Band 2 (2011)). Stuttgart: Buch und Musik.
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3497022934&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)
Heckmair, Bernd/Michl, Werner (2008): Erleben und Lernen. Einführung in die Erlebnispädagogik. München: Reinhardt.
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3940562866&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)
Reiners, Annette (2007): Praktische Erlebnispädagogik. Augsburg: ZIEL.
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3936369348&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)
Pum, Viktoria/Pirner, Manfred L./Lohrer, Jörg (Hrsg.) (2011): Erlebnispädagogik im christlichen Kontext. Dokumentation einer Tagung der Evangelischen Akademie Bad Boll, 2. bis 4. März 2009. Bad Boll: Evangelische Akademie.
- Großer, Achim/Oberländer, Rainer/Lohrer, Jörg/Wiedmayer, Jörg (2005/2011): Sinn gesucht Gott erfahren. Erlebnispädagogik im christlichen Kontext (Band 1 (2005)/Band 2 (2011)). Stuttgart: Buch und Musik.
- Heckmair, Bernd/Michl, Werner (2008): Erleben und Lernen. Einführung in die Erlebnispädagogik. München: Reinhardt.
- Reiners, Annette (2007): Praktische Erlebnispädagogik. Augsburg: ZIEL.
- Pum, Viktoria/Pirner, Manfred L./Lohrer, Jörg (Hrsg.) (2011): Erlebnispädagogik im christlichen Kontext. Dokumentation einer Tagung der Evangelischen Akademie Bad Boll, 2. bis 4. März 2009. Bad Boll: Evangelische Akademie.
#### Links
- Bundesverband Individual- und Erlebnispädagogik e.V. (BE): [https://www.bundesverband-erlebnispaedagogik.de/](https://www.bundesverband-erlebnispaedagogik.de/)

View File

@ -11,6 +11,31 @@ author: Jörg Lohrer
slug: "telegram-octopi"
lang: de
dir: ltr
images:
- file: octopi1.png
role: cover
alt: "Screenshot der OctoPrint-Plugin-Verwaltung während der Installation des Telegram-Plugins — Fortschrittsanzeige läuft"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: octopi2.png
alt: "Screenshot der Konfigurationsmaske des OctoPrint-Telegram-Plugins mit Eingabefeld für den Telegram-Bot-Token"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: octopi3.png
alt: "Screenshot der OctoPrint-Telegram-Plugin-Oberfläche nach erfolgreichem Token-Eintrag — Benutzerliste wird angezeigt, Rechte fehlen noch"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: octopi4.png
alt: "Screenshot der Benutzer-Rechte-Konfiguration mit gesetzten Häkchen bei 'Command' und 'Notify'"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---

View File

@ -11,6 +11,45 @@ author: Jörg Lohrer
slug: "lutherkuerbis"
lang: de
dir: ltr
images:
- file: kuerbis-titelbild.jpg
role: cover
alt: "Fertig geschnitzter Kürbis mit dem Muster einer Lutherrose, innen beleuchtet — glüht warm in der Dunkelheit"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: lutherrose.png
alt: "Schwarz-weiße Vektorgrafik der Lutherrose: Kreuz im Herzen, umgeben von fünfblättriger Rose in einem Ring — als Schnitzschablone aufbereitet"
caption: "Vektorisierte Schablone, abgeleitet von einer Fotovorlage aus dem Web"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
modifications: "Vektorisierung per online-convert.com aus gemeinfreier Fotovorlage; Originalurheber der Fotovorlage unbekannt"
- file: kuerbis-aufschneiden.jpg
alt: "Hände schneiden mit großem Messer den Deckel von einem orangenen Kürbis ab"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: kuerbis-entkernen.jpg
alt: "Mit einem Löffel wird das Fruchtfleisch und die Kerne aus dem Inneren des aufgeschnittenen Kürbis herausgekratzt"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: schablone-aufbringen.jpg
alt: "Papier-Schablone mit Lutherrosen-Motiv wird auf die Außenhaut des entkernten Kürbis geklebt"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: kuerbis-ausschneiden.jpg
alt: "Mit einem Schnitzwerkzeug wird die Lutherrose entlang der Schablone aus der Kürbishaut herausgeschnitten"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---

View File

@ -11,6 +11,19 @@ slug: "pflanzenschild-qr-code"
author: Jörg Lohrer
lang: de
dir: ltr
images:
- file: cura-plugin-change-filment-at-z.png
role: cover
alt: "Screenshot des Cura-Slicers mit aktiviertem 'Change Filament at Z'-Plugin — Konfiguration eines Filamentwechsels in bestimmten Layern"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: qr-code-pflanzenschild.jpg
alt: "Dreieckiges 3D-gedrucktes Pflanzenschild mit aufgedrucktem zweifarbigem QR-Code, steckt in einem Pflanztopf"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---

View File

@ -15,6 +15,48 @@ toc: true
toc_label: "Inhaltsverzeichnis"
toc_icon: "vr-cardboard"
toc_sticky: "true"
images:
- file: 04-aframe.jpg
role: cover
alt: "Screenshot einer A-Frame-WebVR-Szene: 3D-Objekte in einem Browser-Viewport, erstellt mit A-Frame-Framework"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
source_url: "https://codepen.io/joerglohrer/full/dyXQqWG"
- file: 01-immersion-wikipedia.jpg
alt: "Screenshot des Wikipedia-Artikels 'Immersive learning' mit Einstiegsdefinition"
license: UNKNOWN
authors: UNKNOWN
source_url: "https://en.wikipedia.org/wiki/Immersive_learning"
- file: 02-mittelalterliche-kirche.jpg
alt: "Screenshot eines 3D-Modells einer mittelalterlichen Kirche (Calatrava la Nueva, Spanien) auf Sketchfab, erstellt aus 76 Laser-Scans und 4100 Fotos"
license: "https://creativecommons.org/licenses/by-nc/4.0/"
authors: UNKNOWN
source_url: "https://sketchfab.com/3d-models/medieval-church-calatrava-la-nueva-spain-171a047c08bc4dd588cca5ac744e8065"
- file: 03-avatare-erstellen.jpg
alt: "Screenshot der Avatar-Erstellung im Ready Player Me Web-Interface"
license: UNKNOWN
authors: UNKNOWN
- file: 05-pupillendistanz.jpg
alt: "Screenshot der iOS-App 'EyeMeasure' bei der Messung des Pupillenabstands mittels iPhone-Kamera"
license: UNKNOWN
authors: UNKNOWN
- file: 06-vr-adapter-3ddruck.jpg
alt: "3D-gedruckter Adapter zur Befestigung einer VIVE Deluxe Audio Strap an der Oculus Quest 2, frisch aus dem 3D-Drucker"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 07-vive-straps-3ddruck.jpg
alt: "3D-gedruckte Halterungen der VIVE Deluxe Audio Strap, montiert an der Oculus Quest 2"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---

View File

@ -11,6 +11,49 @@ author: Jörg Lohrer
slug: "wordpress-werkstatt"
lang: de
dir: ltr
images:
- file: 04-termine-neu.png
role: cover
alt: "Screenshot der WordPress-Beitragsübersicht mit eingefügtem Shortcode [relilab_termine], der eine Terminliste als Block rendert"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 01-json-import.png
alt: "Screenshot der ACF-Plugin-Oberfläche beim Import einer JSON-Datei mit Feldgruppen-Definitionen"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 02-terminfelder.png
alt: "Screenshot eines WordPress-Beitrags mit zwei neuen ACF-Terminfeldern 'Startet am' und 'Endet am' als Datum-/Zeit-Picker"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 03-kategorien.png
alt: "Screenshot der WordPress-Kategorieverwaltung mit neu angelegter Kategorie 'Termine' samt Unterkategorien"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 05-php-storm.png
alt: "Screenshot der PhpStorm-IDE mit geöffneter PHP-Datei zum add_shortcode()-Aufruf"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 06-termine-listen.png
alt: "Screenshot des PHP-Codes für die Funktion 'termineAusgeben' mit get_posts()-Abfrage und Shortcode-Registrierung"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 07-external-library.png
alt: "Screenshot der PhpStorm-Konfiguration zur Einbindung von WordPress als External Library für Auto-Complete"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---

View File

@ -15,6 +15,13 @@ toc: true
toc_label: "Inhaltsverzeichnis"
toc_icon: "futbol"
toc_sticky: "true"
images:
- file: bibelfussball1.png
role: cover
alt: "Tafel-Skizze eines Fußballfeldes mit Mittellinie, Strafräumen und zwei Toren — Magnetknopf markiert die aktuelle Ballposition"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---

View File

@ -11,6 +11,31 @@ author: Jörg Lohrer
slug: "moodle-iomad-linux"
lang: de
dir: ltr
images:
- file: title-gif.gif
role: cover
alt: "Animiertes Titelbild des Artikels zur Moodle-Server-Installation mit Iomad unter Ubuntu"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 01-netzwerkbruecke.png
alt: "Screenshot der VirtualBox-Netzwerkeinstellungen mit aktivierter Netzwerkbrücke für die Ubuntu-VM"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 02-hosts-eintragen.png
alt: "Terminal-Screenshot mit geöffneter /etc/hosts-Datei im nano-Editor, neuer Eintrag 'moodle.local' wird hinzugefügt"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: "03-config generieren.png"
alt: "Screenshot des Moodle-Installationsassistenten beim automatischen Generieren der config.php"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---

View File

@ -15,6 +15,193 @@ toc: true
toc_label: "Inhaltsverzeichnis"
toc_icon: "house-laptop"
toc_sticky: "true"
images:
- file: 29-autostartordner.jpg
role: cover
alt: "Screenshot des Windows-Autostart-Ordners mit verknüpften OBS- und Zoom-Startlinks für automatischen Start beim Systemstart"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 01-deutsche-tastatur-ubuntu.png
alt: "Screenshot der Ubuntu-Terminal-Dialog zur Konfiguration der deutschen Tastatur via dpkg-reconfigure"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 02-chrome-remote-desktop.png
alt: "Screenshot der Chrome-Remote-Desktop-Installation im Ubuntu-Terminal"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 03-status-chrome-remote.png
alt: "Screenshot des systemctl-Status des chrome-remote-desktop-Dienstes als 'active (running)'"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 04-remotezugriff.png
alt: "Screenshot der Chrome-Remote-Desktop-Konfigurationsseite mit SSH-Befehl und PIN-Eingabe"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 05-systemctl-status.png
alt: "Screenshot der systemctl-status-Ausgabe für chrome-remote-desktop mit aktivem Dienst"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 06-cannot-open-video-device.png
alt: "Terminal-Screenshot der Fehlermeldung 'Cannot open device /dev/video0' bei v4l2-ctl --list-devices"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 07-jetzt-v412-ctl.png
alt: "Terminal-Screenshot der erfolgreichen v4l2-ctl-Geräteliste nach Installation von v4l2loopback"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 08-dummy-video-device.png
alt: "Terminal-Screenshot nach Reboot: virtuelle Kamera fehlt, Dummy-Video-Device muss neu geladen werden"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 09-relilab-technical-host.png
alt: "Screenshot der Chrome-Remote-Desktop-Geräteübersicht mit dem VM-Eintrag 'relilab-technical-host'"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 10-pin-remote-desktop.png
alt: "Screenshot des Chrome-Remote-Desktop-PIN-Eingabefelds für die Remote-Verbindung"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 11-keyboard-tastatur-umstellen.png
alt: "Screenshot der Linux-Keyboard-Einstellungen mit Umstellung auf deutsche Tastaturbelegung"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 12-apps-verknuepfen.png
alt: "Screenshot der Cinnamon-Desktop-Umgebung mit Drag-and-Drop-Verknüpfung von Anwendungen auf den Desktop"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 13-startvirtualcam.png
alt: "Screenshot der OBS-Verknüpfung mit dem Zusatzparameter --startvirtualcam im Startbefehl"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 14-OBS-deutsch-umstellen.png
alt: "Screenshot der OBS-Studio-Einstellungen beim Umschalten der Benutzeroberfläche auf Deutsch"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 15-obs-mit-virtual-cam-starten.png
alt: "Screenshot der OBS-Startbefehl-Konfiguration mit --startvirtualcam-Parameter für automatischen Kamera-Start"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 16-startup-application.png
alt: "Screenshot der Cinnamon-Startup-Applications-Verwaltung mit neu hinzugefügtem OBS-Eintrag"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 17-i-will-only-be-using-OBS.png
alt: "Screenshot des OBS-Auto-Configuration-Wizard mit ausgewählter Option 'I will only be using the virtual camera'"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 18-video1920.png
alt: "Screenshot der OBS-Video-Einstellungen mit Auflösung 1920x1080"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 19-szenensammlung-importieren-OBS.png
alt: "Screenshot des OBS-Menüs 'Szenensammlung importieren' mit Auswahl einer JSON-Datei"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 20-chrome-einrichten.png
alt: "Screenshot des Ubuntu-Keyring-Passwort-Dialogs beim ersten Chrome-Start"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 21-chrome-standard.png
alt: "Screenshot der Google-Chrome-Einstellungen mit gesetzter Option 'Als Standardbrowser festlegen'"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 22-chrome-anmeldung.png
alt: "Screenshot der Google-Account-Anmeldung in Chrome mit aktiviertem Sync"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 23-zoom-anmeldung.png
alt: "Screenshot der Zoom-Client-Anmeldemaske unter Linux"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 24-zoom-sprache-aendern.png
alt: "Screenshot des Zoom-Tray-Menüs mit Sprachauswahl-Untermenü zur Umstellung auf Deutsch"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 25-slides-emojis.png
alt: "Screenshot einer Präsentationsfolie im Chrome-Browser mit fehlenden Emoji-Zeichen als leere Platzhalter"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 26-keyring-problem.png
alt: "Screenshot der Ubuntu-GUI-Fehlermeldung beim Versuch, sich als Root einzuloggen"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 27-startvirtualcam-verknuepft-OBS.jpg
alt: "Screenshot der Windows-Eigenschaften einer OBS-Desktop-Verknüpfung mit --startvirtualcam-Parameter"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 28-shell-startup.png
alt: "Screenshot des Windows-Run-Dialogs mit Befehl 'shell:startup' zum Öffnen des Autostart-Ordners"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: v412-ctl-fehlermeldung.png
alt: "Terminal-Screenshot der v4l2-ctl-Fehlermeldung beim Öffnen des Video-Gerätes"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: virtueller-desktop-titelbild.jpg
alt: "Stilisiertes Titelbild: virtueller Desktop-Arbeitsplatz mit mehreren Bildschirmen und Remote-Verbindung"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---

View File

@ -11,6 +11,43 @@ date: "2023-02-26"
slug: "jojos-schoko-zimt-schnecken"
lang: de
dir: ltr
images:
- file: schneckennudeln-titel.jpg
role: cover
alt: "Goldbraun gebackene Hefeschnecken in einer Kuchenform, Titelbild des Rezepts"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: Hefeteig-mit-Fuellung.jpg
alt: "Ausgerollter Hefeteig, bestrichen mit cremiger Kakao-Zimt-Zucker-Füllung, bereit zum Einrollen"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: 16-Schneckennudeln.jpg
alt: "16 dicht an dicht aufgestellte, rohe Hefeschnecken in einer runden Kuchenform"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: hefeschnecken-in-capelle-backform.jpg
alt: "Gegangene, mit Eimilch bestrichene Hefeschnecken in Kapellen-Backform, bereit für den Ofen"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: schneckennudeln-im-ofen.jpg
alt: "Hefeschnecken im Ofen während des Backens, Oberseite beginnt goldbraun zu werden"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: schneckennudeln-fertig.jpg
alt: "Fertig gebackene, goldbraune Hefeschnecken in der Kuchenform, bereit zum Servieren"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---

View File

@ -11,6 +11,70 @@ date: "2023-03-23"
slug: "gleichnis-vom-saemann"
lang: de
dir: ltr
images:
- file: saemann-title.jpg
role: cover
alt: "Titelbild zum Gleichnis vom Sämann: Collage der fünf KI-generierten Illustrationen im Stil von Eric Carle"
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
authors:
- name: "Jörg Lohrer"
modifications: "Collage aus Midjourney-generierten Bildern im Stil von Eric Carle, Prompts siehe Artikel"
- file: bild1-saemann.jpeg
alt: "Illustration im Stil von Eric Carle: Ein freundlicher Bauer streut Samen in einem offenen Feld, im Hintergrund vier Böden — felsig, dornig, vogelreich und fruchtbar"
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
authors:
- name: "Jörg Lohrer"
modifications: "KI-generiert mit Midjourney v5, Prompt im Stil von Eric Carle"
- file: bild1-alternativ-saemann.jpeg
alt: "Alternative Illustration im Stil von Eric Carle: Bauer beim Säen mit verschiedenen Bodenarten im Hintergrund"
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
authors:
- name: "Jörg Lohrer"
modifications: "KI-generiert mit Midjourney v5, alternative Variante zu Bild 1"
- file: bild2-saemann.jpeg
alt: "Illustration im Stil von Eric Carle: Kleine, schwache Pflanzen, die mit wenig Erde auf felsigem Boden zu wachsen beginnen"
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
authors:
- name: "Jörg Lohrer"
modifications: "KI-generiert mit Midjourney v5"
- file: bild2-alternativ-saemann.jpeg
alt: "Alternative Illustration im Stil von Eric Carle: Keimende Pflanzen auf steinigem Grund"
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
authors:
- name: "Jörg Lohrer"
modifications: "KI-generiert mit Midjourney v5, alternative Variante zu Bild 2"
- file: bild3-saemann.jpeg
alt: "Illustration im Stil von Eric Carle: Junge Pflanzen werden von Dornen umklammert und erstickt"
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
authors:
- name: "Jörg Lohrer"
modifications: "KI-generiert mit Midjourney v5"
- file: bild4-saemann.jpeg
alt: "Illustration im Stil von Eric Carle: Fröhliche Vögel picken Samen vom Boden und fressen sie, bevor sie keimen können"
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
authors:
- name: "Jörg Lohrer"
modifications: "KI-generiert mit Midjourney v5"
- file: bild5-saemann.jpeg
alt: "Illustration im Stil von Eric Carle: Große, gesunde Pflanzen tragen reiche Früchte auf fruchtbarem Boden, der Bauer steht lächelnd daneben"
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
authors:
- name: "Jörg Lohrer"
modifications: "KI-generiert mit Midjourney v5"
- file: screen-chatgpt-saemann.png
alt: "Screenshot des ChatGPT-Dialogs: Eingabe der Anfrage zum Gleichnis vom Sämann für einen 8-Jährigen und KI-generierte Antwort in fünf Bildbeschreibungen"
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
authors:
- name: "Jörg Lohrer"
modifications: "Screenshot einer ChatGPT-4-Interaktion, Prompt vom Autor verfasst"
---
# Das Gleichnis vom Sämann

View File

@ -11,6 +11,43 @@ date: "2023-04-07"
slug: "dampfnudeln"
lang: de
dir: ltr
images:
- file: Hefefreuden.jpg
role: cover
alt: "Titelbild: Dampfnudeln und Hefezopf auf einem Tisch, frisch aus Dampfgarer und Ofen"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: Hefeteig.jpg
alt: "Aufgegangener Hefeteig in einer Rührschüssel, glatt und elastisch, nach 30 Minuten Ruhezeit"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: Dampfnudeln-auf-Lochblech.jpg
alt: "Sechs runde Hefeteigstücke zum Dampfgaren auf einem gelochten Dampfgarblech"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: Dampfnudeln-im-Dampfgarer.jpg
alt: "Gegarte, aufgegangene Dampfnudeln im geöffneten Dampfgarer, glänzend und flaumig"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: Dampfnudel-mit-Vanillesosse.jpg
alt: "Dampfnudel auf Teller angerichtet, übergossen mit goldgelber Vanillesoße"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: Hefezopf.jpg
alt: "Frisch gebackener, dreifach geflochtener Hefezopf, goldbraun glänzend nach dem Einpinseln mit Ei"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---

View File

@ -11,7 +11,25 @@ cover:
tags: [ "WordPress", "Padlet", "Kanban", "TaskCards", "horizontales Scrollen" ]
lang: de
dir: ltr
images:
- file: wordpress-horizontales-scrollen.gif
role: cover
alt: "Animierter Screenshot: WordPress-Seite mit horizontal scrollbaren Spalten, die Beiträge im Kanban-Stil nebeneinander zeigen"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: spalten-als-posts-block.png
alt: "Screenshot des Stackable 'posts block'-Plugins in WordPress mit Spaltenansicht nach Kategorien"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: posts-per-drag-and-drop-sortieren.png
alt: "Screenshot der WordPress-Beitragsliste mit aktiviertem 'Intuitive Custom Post Order'-Plugin — Beiträge werden per Drag & Drop sortiert"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---
Eine **Sammlung von Liedern für den Religionsunterricht** ist auf Instagram in Zusammenarbeit von [@relimomente](https://www.instagram.com/relimomente/) [@ezpz.lemon.sqz](https://www.instagram.com/ezpz.lemon.sqz) und [@colibri260](https://www.instagram.com/colibri260) entstanden und wurde zunächst **[hier auf TaskCards](https://www.taskcards.de/#/board/16af7347-ec26-468e-a093-34549dd2dae3/view)** veröffentlicht.

View File

@ -11,6 +11,14 @@ author: Jörg Lohrer
tags: [ "Offenheit", "OER", "WordPress", "Nextcloud", "Element", "Community" ]
lang: de
dir: ltr
images:
- file: offenheit-wesentlich.png
role: cover
alt: "KI-generierte Aquarell-Illustration: Silhouetten von Menschen aller Geschlechter und Altersgruppen, die ineinander übergehen und sich überlappen — Symbol einer Community of Trust"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
modifications: "KI-generiert mit Midjourney v6.0, Prompt: A Community of Trust based on Openness, silhouettes of people of all genders and ages that merge into each other and overlap, watercolors --v 6.0 --seed 1235164279"
---
# Offenheit - das Wesentliche

View File

@ -11,6 +11,13 @@ author: Jörg Lohrer
tags: [ "Offenheit", "OER", "MarkDown", "Community" ]
lang: de
dir: ltr
images:
- file: bottomup-markdown.png
role: cover
alt: "Titelbild zur OER-Camp-Session 'BottomUp MarkDown' — Symbol für die 5V-Freiheiten von Open Content in Verbindung mit der Markdown-Sprache"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---
## Eine Session-Idee auf dem #OERcamp 24 in Hamburg

View File

@ -27,6 +27,15 @@ creator:
name: Comenius-Institut
id: https://ror.org/025e8aw85
type: Organization
images:
- file: kibedenken.png
role: cover
alt: "Ein junger Roboterjunge mit gesenktem Kopf betrachtet seine Spiegelung im Wasser, im fotorealistischen Stil einer Canon EOS 5D Mark IV"
caption: "Referenziert auf Narziss aus der griechischen Mythologie und die Illustration von Caravaggio (siehe [Wikipedia #Narziss](https://de.wikipedia.org/wiki/Narziss#))"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
modifications: "KI-generiert mit Midjourney v6.0, Prompt: photographed with the Canon EOS 5D Mark IV a young robot boy with his head down, looking at his reflection in water --v6.0"
---
# #KIBedenken - Bewusstsein

View File

@ -60,6 +60,16 @@ learningResourceType:
educationalLevel:
- https://w3id.org/kim/educationalLevel/level_A
datePublished: '2025-03-04'
images:
- file: dezentrale-oep-oer.png
role: cover
alt: "Ein in den Sand gezeichneter Strauß mit den Buchstaben 'OER' — Sinnbild für offene Bildung und freien Wissensaustausch, gleichzeitig Wortspiel-Verbindung zu Nostr (Ostrich = Strauß)"
caption: "Analog zum Ichthys-Fisch als geheimem Erkennungszeichen: Symbol einer Gemeinschaft, die Wissen offen, unabhängig und widerstandsfähig teilt"
license: "https://creativecommons.org/licenses/by/4.0/deed.de"
authors:
- name: "Jörg Lohrer"
- name: "Steffen Rörtgen"
- name: "Bastian Granas"
---
# Gemeinsam die Bildungszukunft gestalten: Dezentrale OEP und OER als Wegbereiter

View File

@ -3,138 +3,142 @@
Du (Claude, nächste Session) oder ich (Jörg, später) kommen hier zurück.
Dieses Dokument sagt: was ist der Zustand, was wartet, wo liegen die Fäden.
## Zustand (siehe `STATUS.md` für Details)
## Zustand (Details in `STATUS.md`)
Die SvelteKit-SPA unter `svelte.joerg-lohrer.de` ist **fertig und live**.
35 geplante Tasks + einige Erweiterungen abgeschlossen. Branch `spa` hat
alle Commits. Ein Git-Merge nach `main` und Deploy auf die Hauptdomain ist
**noch nicht** erfolgt — das kommt erst nach dem Cutover-Plan.
**Die Nostr-Publish-Pipeline ist live.** Alle 18 Posts sind publiziert als
`kind:30023`-Events auf 5 Relays, 91 Bilder auf 2 Blossom-Servern. Die
SvelteKit-SPA unter `svelte.joerg-lohrer.de` rendert alles ordentlich.
**Das inhaltliche Kernziel des Gesamtprojekts ist damit erreicht.**
Der Rest sind Feinschliff- und Cutover-Aufgaben.
## Was als Nächstes ansteht
Drei Optionen, ordered by natürlichkeit der Fortsetzung:
### Option 1 — CI-End-to-End-Test ⬅ kleinstes Offene
### Option 1 — Publish-Pipeline bauen
**Voraussetzung erledigt:** Forgejo → GitHub Push-Mirror läuft, GitHub-Secrets
gesetzt (Details in `docs/github-ci-setup.md`).
**Warum:** aktuell muss Jörg jeden neuen Post manuell mit `nak event` signieren
und publishen (siehe `preview/spa-mini/README.md`, Referenzbefehl in den
Brainstorm-Notizen). Eine Publish-Pipeline automatisiert:
**Noch zu tun:**
1. In GitHub → Actions → „Publish Nostr Events" → „Run workflow" → Branch
`main`. Erwartung: Pre-Flight grün, 0 Posts (kein Content-Diff), Exit 0.
2. Optional: Minimaler Edit in einem Post → commit → push → warten bis
Mirror auf GitHub synct → Workflow triggert automatisch → 1 Post als
`update` publiziert → Log-Artefakt prüfen.
1. Markdown-Post in `content/posts/` bearbeiten / neu anlegen
2. Git-Commit + push auf `main`
3. GitHub Action signiert Event via NIP-46 (Amber-Bunker), pushed zu allen
Relays aus `kind:10002`, lädt Bilder zu Blossom, lädt Altbild-Assets
ggf. zu All-Inkl via SSH/rsync.
Danach ist Task 22 komplett abgeschlossen.
**Was existiert:** Spec vollständig unter
`docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`. Plan
**noch nicht geschrieben.**
### Option 2 — Cutover auf `joerg-lohrer.de`
**Nächster Konkreter Schritt:**
```
superpowers:writing-plans
```
mit dem Publish-Spec als Input.
**Voraussetzung:** Option 1 optional, aber nicht blockierend. Die Pipeline
läuft ja schon, ob manuell oder via CI ist für den Cutover egal.
**Vorarbeiten:**
- SSH-Zugang zu All-Inkl klären (Premium-Tarif angefragt, Status prüfen)
- Deno ≥ 2.x installiert?
- GitHub Actions-Repo-Secrets vorbereiten (`BUNKER_URL`, `ALLINKL_DEPLOY_ROOT`,
`SSH_DEPLOY_KEY`, `AUTHOR_PUBKEY_HEX`)
**Schritte:**
1. In All-Inkl KAS die Domain `joerg-lohrer.de` auf den SvelteKit-Webroot
umhängen (aktuell: `svelte.joerg-lohrer.de``/www/htdocs/v109928/joerglohrer28/`
oder welcher Ordner auch immer).
2. SvelteKit-SPA deployen, sofern sie nicht schon dort liegt.
3. Live-Check: `curl -sI https://joerg-lohrer.de/` → sollte die neue SPA
liefern, nicht mehr Hugo.
### Option 2 — Menü-Navigation + Impressum auf der SPA
Hugo-Altbestand bleibt als Archiv im `hugo-archive`-Branch.
**Warum:** kleine UX-Ergänzung, die das SPA-Erlebnis runder macht.
### Option 3 — Menü-Navigation + Impressum in der SPA
- Header-Navigation in `app/src/routes/+layout.svelte` ergänzen (Home, Archiv,
Impressum, evtl. Mastodon-Link)
- `/impressum/`-Route anlegen mit rechtlichem Text
- ggf. Archives-Route als eigene Liste mit Gruppierung nach Jahr
**Unabhängig von allem anderen**, kann parallel gemacht werden.
**Aufwand:** ~30-60 min je nach Layout-Wunsch. Kein Spec-Update nötig,
ist in SPA-Spec §2 bereits als Ziel erwähnt.
- Header-Navigation in `app/src/routes/+layout.svelte` (Home, Archiv, Impressum,
Mastodon-Link)
- `/impressum/`-Route mit rechtlichem Text
### Option 3 — Cutover auf Hauptdomain
**Aufwand:** 3060 min.
**Warum:** `joerg-lohrer.de` liefert aktuell noch Hugo aus. Sobald genug
Altposts als Events publiziert sind und die Publish-Pipeline läuft, kann die
SvelteKit-SPA auf die Hauptdomain umziehen. Das ist aber **kein Task jetzt**
— muss auf Publish-Pipeline warten, sonst brechen Backlinks zu Posts, die
noch nicht als Events existieren.
### Option 4 — Pipeline weg von GitHub (self-hosted CI)
**Reihenfolge:** Option 1 → Publish-Pipeline + einmaliger Massen-Import der
übrigen 17 Altposts → dann Option 3.
**Wann:** Wenn der Optiplex-Server steht und ein zentraler Ort für Dienste
existiert.
**Varianten:**
- **Cron / systemd-Timer** auf dem Optiplex, der alle X Minuten `git pull &&
deno task publish` macht. Einfach, minimaler Setup.
- **Woodpecker-CI** als Docker-Container neben Forgejo. Volle Push-getriggerte
Pipeline ohne GitHub.
Der Pipeline-Code selbst (`publish/src/**`) ist CI-agnostisch — nur die
Trigger-Konfiguration ändert sich.
## Schnell-Orientierung für die nächste Claude-Session
Lies in dieser Reihenfolge:
1. `docs/STATUS.md` (5 min)
2. `docs/HANDOFF.md` (= dieses Dokument)
3. Die relevante Spec, je nachdem was drankommt:
- Publish-Pipeline: `docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`
- SPA-Anpassungen: `docs/superpowers/specs/2026-04-15-nostr-page-design.md`
Nutze den Skill unter `.claude/skills/joerglohrerde-workflow.md` für
wiederkehrende Kommandos.
3. Für CI-Themen: `docs/github-ci-setup.md`
4. Für Pipeline-Fragen: `docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`
## Dev-Kommandos
```sh
# Unit-Tests (Vitest)
# SPA-Tests
cd app && npm run test:unit
# E2E-Tests (Playwright)
cd app && npm run test:e2e
# Type-Check
cd app && npm run check
# Dev-Server (Port 5173)
cd app && npm run dev
# Production-Build + Deploy
# SPA-Build + Deploy
cd app && npm run build && cd .. && ./scripts/deploy-svelte.sh
```
## Manuelles Publishen (bis Publish-Pipeline fertig ist)
Einen Post aus `content/posts/<ordner>/index.md` als kind:30023-Event
publizieren:
```sh
# Body ohne Frontmatter extrahieren
awk 'BEGIN{in_fm=0; past_fm=0} NR==1 && /^---$/ {in_fm=1; next} in_fm && /^---$/ {in_fm=0; past_fm=1; next} past_fm {print}' content/posts/<ordner>/index.md > /tmp/body.md
# Bunker-URL aus .env.local
BUNKER_URL=$(grep -E '^BUNKER_URL=' .env.local | sed 's/^BUNKER_URL=//')
# Event bauen, signieren, zu Relays pushen
# (Tags: d, title, summary, image, published_at, t×n)
# Siehe "dezentrale-oep-oer"-Beispiel in der Brainstorm-Historie
```
Für Bilder: Upload zu Blossom mit `nak blossom upload`:
```sh
nak blossom upload --server https://blossom.edufeed.org --sec "$BUNKER_URL" <bild>
# Publish-Pipeline
cd publish && deno task check # pre-flight
cd publish && deno task publish --dry-run # diff-modus simulation
cd publish && deno task publish # diff-modus echt
cd publish && deno task publish --force-all # alle posts
cd publish && deno task publish --post <slug> # einen post
cd publish && deno task test # 59 tests
```
## Bekannte Stolperfallen
- **Amber-Bunker:** bei neuer Bunker-URL müssen globale Permissions in Amber
zurückgesetzt werden, sonst hängt `nak` auf den Signatur-Request.
- **All-Inkl FTPS:** bricht mit TLS 1.3 die Data-Connection ab. Script
nutzt `--tls-max 1.2`. Bei SSH-Umstellung: rsync fixen, TLS-Flag raus.
- **Svelte 5 Runes:** `$props()`-Werte müssen via `$derived()` in lokale
Variablen, sonst `state_referenced_locally`-Warning.
- **applesauce-relay API:** ist RxJS-basiert. `pool.request(relays, filter)`
returned `Observable<NostrEvent>` (nicht die Tupel-`subscribe({next: msg
if msg[0]==='EVENT'})`-Form).
- **Slug-Normalisierung:** alle Frontmatter-Slugs sind lowercase (Commit
`d17410f`). Beim Publishen 1:1 übernehmen, keine Runtime-Transformation.
- **Amber-Bunker:** bei neuer Bunker-URL müssen die zwei Permissions
(`get_public_key`, `sign_event`) in Amber auf „Allow + Always" gesetzt
werden, bevor Publish-Requests verarbeitet werden. Siehe
`docs/github-ci-setup.md` für Details.
- **`CLIENT_SECRET_HEX`** in `.env.local` identisch mit GitHub-Secret —
sorgt dafür, dass sich beide Umgebungen bei Amber mit derselben App
anmelden. Rotieren nur bei bewusstem Neu-Pairing in Amber.
- **`relay.damus.io`** bestätigt Events manchmal nicht mit `OK`. Bekanntes
Damus-Verhalten, wird toleriert (MIN_RELAY_ACKS=2, andere 4 Relays sind
zuverlässig).
- **Svelte 5 Runes:** `$props()`-Werte via `$derived()` in lokale Variablen.
- **Hugo-quotierte Dates:** `date: "2023-02-26"` ist ein YAML-String, nicht
ein Date-Objekt. `validatePost` coerced das automatisch; in neuen Posts
am besten ohne Quotes schreiben.
## Offene UNKNOWN-Einträge zur späteren Recherche
Im VR-Post (`content/posts/2021-08-15-virtual-reality/index.md`) sind
4 Bilder als `license: UNKNOWN / authors: UNKNOWN` markiert:
- `01-immersion-wikipedia.jpg` (Wikipedia-Screenshot)
- `02-mittelalterliche-kirche.jpg` (Sketchfab — Lizenz ist CC BY-NC, Fotograf fehlt)
- `03-avatare-erstellen.jpg` (Ready Player Me)
- `05-pupillendistanz.jpg` (EyeMeasure iOS App)
Pipeline loggt Warnungen, publisht aber trotzdem. Recherche-Notizen in
`docs/redaktion-bild-metadaten.md`.
## Session-Kontext
Hilfreich beim Wiedereinstieg mit Claude:
- Branch-Check: `git log --oneline -10 spa main hugo-archive`
- Live-Check: `curl -sI https://svelte.joerg-lohrer.de/`
- Publish-Status: `nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.damus.io 2>/dev/null | jq -c '{d: (.tags[] | select(.[0]=="d") | .[1]), title: (.tags[] | select(.[0]=="title") | .[1])}'`
- Branch-Check: `git log --oneline -10 spa main`
- Live-Check SPA: `curl -sI https://svelte.joerg-lohrer.de/`
- Event-Count: `nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.primal.net 2>/dev/null | jq -s 'length'` → 18
- Pipeline-Tests: `cd publish && deno task test` → 59 grün
## Community-Wiki-Entwürfe
Liegen im Repo, noch nicht extern veröffentlicht:
- `docs/wiki-entwurf-nostr-bild-metadaten.md` — DE
- `docs/wiki-draft-nostr-image-metadata.md` — EN
Können als NIP-Proposal oder auf nostrbook.dev eingebracht werden, jetzt wo
die Konvention in der Praxis validiert ist.

View File

@ -1,6 +1,6 @@
# Projekt-Status: joerg-lohrer.de → Nostr-basierte SPA
**Stand:** 2026-04-15
**Stand:** 2026-04-18
## Kurzfassung
@ -9,13 +9,14 @@ Site-Generator zu einer dezentralen Nostr-basierten SPA überführt. Posts
existieren als signierte Events (NIP-23, `kind:30023`) auf Public-Relays und
werden zur Laufzeit im Browser gerendert.
## Drei parallele Webseiten
## Vier parallele Webseiten
| URL | Status | Rolle |
|---|---|---|
| `https://joerg-lohrer.de/` | live, unverändert | **Hugo-Altbestand** (wird noch nicht ersetzt) |
| `https://spa.joerg-lohrer.de/` | live | **Vanilla-HTML-Mini-Spike** (Proof of Concept, ~250 Zeilen HTML+JS) |
| `https://joerg-lohrer.de/` | live, unverändert | **Hugo-Altbestand** (bleibt bis Cutover) |
| `https://spa.joerg-lohrer.de/` | live | **Vanilla-HTML-Mini-Spike** (Proof of Concept) |
| `https://svelte.joerg-lohrer.de/` | live | **SvelteKit-SPA** (35-Task-Plan komplett) |
| `https://staging.joerg-lohrer.de/` | live, leer | **Staging** (Webroot `joerglohrer26/` für Pipeline-Entwicklung; FTP-Creds in `.env.local`) |
Die SvelteKit-SPA unter `svelte.joerg-lohrer.de` ist die Ziel-Implementierung.
`spa.joerg-lohrer.de` bleibt als schlanke Referenz erhalten. Hugo läuft weiter,
@ -25,81 +26,110 @@ bis die Publish-Pipeline steht und der Cutover auf die Hauptdomain erfolgt.
- **Autoren-Pubkey:** `npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9`
(hex: `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41`)
- **Publizierte Events:** ~10 Langform-Posts (`kind:30023`), darunter
`dezentrale-oep-oer`, `offenheit-das-wesentliche`, `gleichnis-vom-saemann`,
`bibelfussball`, `dampfnudeln` u. a.
- **Publizierte Events:** **18 Langform-Posts** (`kind:30023`) — alle Altposts
via Publish-Pipeline migriert (Commit `0c6fdd1`, Log in
`docs/publish-logs/2026-04-18-force-all-migration.json`).
- **Relay-Liste** (`kind:10002`): `relay.damus.io`, `nos.lol`,
`relay.primal.net`, `relay.tchncs.de`, `relay.edufeed.org`
- **Blossom-Server** (`kind:10063`): `blossom.edufeed.org`, `blossom.primal.net`
Bilder des ersten „experimentell publizierten" Posts (`dezentrale-oep-oer`)
liegen auf Blossom. Weitere 17 Altposts haben ihre Bilder noch unter dem
ursprünglichen Hugo-Permalink auf All-Inkl.
**91 Bilder** auf beiden Blossom-Servern. Alle Events enthalten hash-basierte
Blossom-URLs. SPA rendert alle Posts einheitlich — kein Legacy-Pfad, keine
rsync-Artefakte.
## Repo-Struktur
```
joerglohrerde/
├── content/posts/ # Markdown-Quelle (18 Posts, wird vom Publish-Skript gelesen)
├── content/posts/ # 18 Markdown-Posts, alle mit structured images: im Frontmatter
├── app/ # SvelteKit-SPA (Ziel-Implementation)
├── preview/spa-mini/ # Vanilla-HTML-Mini-Spike (Referenz)
├── publish/ # NOCH NICHT ANGELEGT — Publish-Pipeline (Task 1 aus Plan)
├── scripts/
│ └── deploy-svelte.sh # FTPS-Deploy nach svelte.joerg-lohrer.de
├── docs/
│ ├── STATUS.md # Dieses Dokument
│ ├── HANDOFF.md # Wie man hier weitermacht
│ ├── redaktion-bild-metadaten.md # Checkliste, Bild-Durchgang (abgearbeitet)
│ ├── wiki-entwurf-nostr-bild-metadaten.md # Wiki-Konvention deutsch
│ ├── wiki-draft-nostr-image-metadata.md # Wiki-Konvention englisch
│ └── superpowers/
│ ├── specs/ # SPA-Spec + Publish-Pipeline-Spec
│ └── plans/ # SPA-Implementation-Plan (35 Tasks, abgeschlossen)
│ ├── specs/ # SPA + Publish-Pipeline + Bild-Metadaten-Konvention
│ └── plans/
│ ├── 2026-04-15-spa-sveltekit.md # erledigt
│ └── 2026-04-16-publish-pipeline.md # ⬅ als nächstes
├── .claude/
│ ├── skills/ # Repo-spezifischer Claude-Skill
│ └── settings.local.json # Claude-Session-State (nicht committen? aktuell schon)
└── .env.local # Gitignored: FTP-Creds + Bunker-URL
│ └── settings.local.json # Claude-Session-State (gitignored)
└── .env.local # Gitignored: FTP-Creds, Bunker-URL, Publish-Pipeline-Keys
```
## Branch-Layout (Git)
- **`main`** — kanonischer Zweig. Enthält Content, Specs, Pläne, Deploy-Scripts,
`.claude/`-Skill. Schlanker als früher (kein Hugo-Artefakt mehr).
- **`spa`** — aktueller Arbeits-Branch. SvelteKit-SPA in `app/` komplett
implementiert und live. **Aktuell vor `main` mit allen `spa:`-Commits.**
- **`hugo-archive`** — Orphan-Branch mit dem letzten funktionierenden
Hugo-Zustand, eingefroren. Rollback über `git checkout hugo-archive && hugo build`.
- **`main`** — kanonischer Zweig.
- **`spa`** — aktueller Arbeits-Branch, vor `main`. SvelteKit-SPA live,
Content-Migration (Bild-Metadaten) abgeschlossen, Publish-Pipeline geplant.
- **`hugo-archive`** — Orphan-Branch mit Hugo-Zustand, eingefroren.
## Setup-Zustand
Einmalig manuell erledigt:
- ✅ Amber-Bunker-URL in `.env.local` als `BUNKER_URL`
- ✅ SPA-FTP-Creds (`spa.joerg-lohrer.de`) in `.env.local` als `SPA_FTP_*`
- ✅ SvelteKit-FTP-Creds (`svelte.joerg-lohrer.de`) in `.env.local` als `SVELTE_FTP_*`
- ✅ `kind:10002`-Event publiziert
- ✅ `kind:10063`-Event publiziert
- ✅ Subdomains mit TLS + HSTS (`max-age=300`)
- ✅ FTP-Creds für alle Subdomains (SPA, SVELTE, STAGING) in `.env.local`
- ✅ `AUTHOR_PUBKEY_HEX` und `BOOTSTRAP_RELAY=wss://relay.primal.net` in `.env.local`
- ✅ `kind:10002`-Event publiziert (Relay-Liste)
- ✅ `kind:10063`-Event publiziert (Blossom-Server)
- ✅ Subdomains mit TLS + HSTS
- ✅ Staging-Subdomain `staging.joerg-lohrer.de` → Webroot `joerglohrer26/`
Alles in `.env.local` — gitignored, nicht committet.
## Offene Punkte / Nicht-in-Scope
## Offene Punkte
- **Publish-Pipeline** (Spec vorhanden unter `docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`, Plan noch nicht geschrieben)
- **CI-Mirror** Forgejo → GitHub eingerichtet, GitHub-Secrets gesetzt.
End-to-End-Test (Content-Commit, Workflow-Trigger, CI-Lauf) noch offen.
Später: Migration auf Woodpecker oder Cron auf Optiplex möglich
(siehe `docs/github-ci-setup.md`).
- **Menü-Navigation** in der SPA (Home / Archiv / Impressum / Kontakt)
- **Impressum-Seite** (braucht rechtlichen Text)
- **Meta-Stubs für Social-Previews und SEO** (wird Teil der Publish-Pipeline)
- **SSH-Zugang zu All-Inkl** (laut Notiz von Jörg: Premium-Tarif im Kommen → rsync statt FTPS möglich)
- **Cutover auf `joerg-lohrer.de`** (Hauptdomain bekommt dann die SvelteKit-SPA)
- **Cutover auf `joerg-lohrer.de`** (Pipeline läuft, Voraussetzung erfüllt;
Hauptdomain kann auf SvelteKit-SPA umgestellt werden)
- **5 UNKNOWN-Einträge** im `virtual-reality`-Post zur späteren Recherche
(Wikipedia-Screenshot, Sketchfab-Fotograf, Ready-Player-Me, EyeMeasure-App)
## Erledigt seit 2026-04-15
- ✅ Content-Migration: alle 18 Posts haben strukturierte `images:`-Liste
im Frontmatter (91 Bilder, mit Alt-Text, Lizenz, Autor:innen, ggf. Caption
und Modifications). Commit `c023b59`.
- ✅ Erlebnispädagogik-Post: tote Amazon-Hotlinks entfernt.
- ✅ Spec, Plan und Bild-Metadaten-Konvention geschrieben.
- ✅ Community-Wiki-Entwürfe (DE + EN) für Nostr-Bildattribution.
- ✅ **Publish-Pipeline komplett implementiert**, 22 Tasks aus dem Plan:
- 18 Code-Tasks (Phase 16), 59 Unit-Tests grün
- Stabile NIP-46-Anbindung via `CLIENT_SECRET_HEX` für wiederverwendbare
App-Identität in Amber
- `validatePost` akzeptiert auch string-dates (für Hugo-Kompatibilität)
- ✅ **Alle 18 Altposts publiziert** als `kind:30023`-Events (Commit `0c6fdd1`,
Log in `docs/publish-logs/2026-04-18-force-all-migration.json`).
- ✅ **91 Bilder** auf beiden Blossom-Servern.
- ✅ SPA rendert alle Posts mit Bildern von Blossom (visuell verifiziert).
- ✅ **GitHub-Actions-Workflow** angelegt (`.github/workflows/publish.yml`).
- ✅ Forgejo → GitHub Push-Mirror eingerichtet, GitHub-Secrets gesetzt.
## Live-Verifikation
Jederzeit:
```sh
curl -sI https://svelte.joerg-lohrer.de/ | head -3
curl -sI https://spa.joerg-lohrer.de/ | head -3
curl -sI https://staging.joerg-lohrer.de/ | head -3
```
## Kontakt zur Implementierung
Alle Design-Entscheidungen in:
- `docs/superpowers/specs/2026-04-15-nostr-page-design.md` (SPA)
- `docs/superpowers/specs/2026-04-15-publish-pipeline-design.md` (Publish)
- `docs/superpowers/plans/2026-04-15-spa-sveltekit.md` (35-Task-Plan, abgeschlossen)
- `docs/superpowers/specs/2026-04-15-publish-pipeline-design.md` (Publish, Blossom-only)
- `docs/superpowers/specs/2026-04-16-image-metadata-convention.md` (Bild-Metadaten-YAML)
- `docs/superpowers/plans/2026-04-16-publish-pipeline.md` (24 Tasks, als nächstes)
Für die nächste Session: `docs/HANDOFF.md` lesen.
Für die nächste Session: **`docs/HANDOFF.md`** lesen.

87
docs/github-ci-setup.md Normal file
View File

@ -0,0 +1,87 @@
# GitHub-CI-Setup für die Publish-Pipeline
**Kontext:** Das primäre Repo liegt in **Forgejo** (self-hosted). Für CI nutzen
wir GitHub als **Push-Mirror-Ziel**, weil Forgejo keine Woodpecker-Integration
hat. GitHub Actions triggert automatisch bei Push auf `main` mit Änderungen
unter `content/posts/**`.
## Setup-Schritte
### 1. Forgejo → GitHub Push-Mirror
In Forgejo:
- Repo → **Settings → Mirrors → Push Mirror hinzufügen**
- Ziel-URL: das entsprechende GitHub-Repo (z. B. `https://github.com/<user>/joerglohrerde.git`)
- Authentifizierung: GitHub-Personal-Access-Token (`repo`-Scope)
- Intervall: nach Belieben (z. B. alle 8 Stunden, oder „bei jedem Push")
### 2. GitHub-Repository-Secrets
In GitHub, Repo → **Settings → Secrets and variables → Actions**:
Vier Repository-Secrets anlegen (nicht Environment-Secrets — wir haben keine Environments):
| Name | Wert | Quelle |
|---|---|---|
| `BUNKER_URL` | `bunker://<hex>?relay=wss://...&secret=...` | aus `.env.local` |
| `AUTHOR_PUBKEY_HEX` | `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41` | aus `.env.local` |
| `BOOTSTRAP_RELAY` | `wss://relay.primal.net` | aus `.env.local` |
| `CLIENT_SECRET_HEX` | `929f0cd946fd5266e63ccdb066ce7a0cc93391133bfce6098fe633fc72e03e96` | aus `.env.local` |
**Wichtig:**
- Alle vier **müssen** gesetzt sein, sonst schlägt der Workflow fehl.
- Der `CLIENT_SECRET_HEX` ist **identisch** mit dem in `.env.local` — damit sich
CI-Runner und lokaler Rechner bei Amber mit **derselben Client-Identität**
anmelden. Die Permissions in Amber gelten dann für beide.
### 3. Workflow-Datei
Liegt in `.github/workflows/publish.yml`. Triggert auf:
- `push` auf `main` mit Änderungen unter `content/posts/**`
- `workflow_dispatch` (manuelles Triggern über das GitHub-UI, optional mit `force_all=true`)
### 4. Secrets rotieren
Wenn der Bunker-Pairing-Secret mal kompromittiert wird oder Amber neu
eingerichtet wird:
1. In Amber neue Bunker-URL erzeugen
2. Lokale `.env.local` aktualisieren
3. GitHub-Secret `BUNKER_URL` ebenfalls aktualisieren (Settings → Secrets → edit)
4. In Amber für die neue App wieder "Allow + Always" für
`get_public_key` + `sign_event` setzen
Der `CLIENT_SECRET_HEX` muss in der Regel **nicht** rotiert werden — nur wenn
du die App in Amber komplett neu pairen willst. Wenn du ihn doch änderst, muss
Amber die App neu registrieren (siehe Setup).
## Monitoring
- **Workflow-Runs:** GitHub → Actions → "Publish Nostr Events"
- **Logs pro Run:** pro Run ein Artefakt `publish-log` mit der `publish-*.json`,
30 Tage Aufbewahrung
- **Lokal laufen bleibt möglich** via `cd publish && deno task publish …`
CI ist eine zusätzliche Automatisierung, kein Zwang.
## Bekannte Einschränkungen
- **Amber muss online sein** während CI-Runs, sonst scheitert die Bunker-
Signatur. Wenn das Handy tot ist: Workflow failed → einfach neu triggern,
sobald Amber wieder erreichbar.
- **`relay.damus.io`** antwortet gelegentlich nicht mit OK; das ist
ein bekanntes Damus-Verhalten und wird von `MIN_RELAY_ACKS=2` toleriert.
- **Staging-Subdomain (`staging.joerg-lohrer.de`)** hat nichts mit dieser
Pipeline zu tun — sie gehört zum SPA-Deploy. Die Publish-Pipeline nutzt
ausschließlich Blossom für Bild-Hosting.
## Migration weg von GitHub (später)
Wenn Woodpecker oder ein anderer self-hosted Runner aufgesetzt wird, bleibt
der Deno-Workflow derselbe — nur die CI-Konfiguration ändert sich:
- `.github/workflows/publish.yml``.woodpecker.yaml` (oder `.gitea/workflows/`)
- Secrets in Woodpecker statt GitHub
- Trigger-Bedingungen analog (push main + path filter)
Der Pipeline-Code selbst (`publish/src/**`) ist CI-agnostisch und braucht keine
Änderung.

View File

@ -0,0 +1,372 @@
{
"run_id": "6356c2c9-37c6-4927-b906-7943bb59d3c0",
"started_at": "2026-04-18T04:44:43.558Z",
"ended_at": "2026-04-18T04:47:34.238Z",
"mode": "force-all",
"posts": [
{
"slug": "premium-freemium-mium-mium-mium",
"status": "success",
"action": "new",
"event_id": "7f18e5fbc825f16d118281e4c56ce8a989d2647fedcd3d4e45a8df0e897a395c",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 1,
"duration_ms": 4294
},
{
"slug": "erlebnispadagogik-im-handbuch-jugend-evangelische-perspektive",
"status": "success",
"action": "new",
"event_id": "ee5ecc397b4bfbff268ec3c53187f0b2b4a03958e14df2728c27145a72b85941",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [],
"images_uploaded": 0,
"duration_ms": 3111
},
{
"slug": "telegram-octopi",
"status": "success",
"action": "new",
"event_id": "bee5a9150ce2055e17d729772b4a998afbd3333e6224beb72fd2152ee2d0c05e",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 4,
"duration_ms": 5169
},
{
"slug": "lutherkuerbis",
"status": "success",
"action": "new",
"event_id": "7715c4359da95c0459a6e65fd25c17c3c8c169e83fe68acb9f98ed34dd44e4e6",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 6,
"duration_ms": 7921
},
{
"slug": "pflanzenschild-qr-code",
"status": "success",
"action": "new",
"event_id": "f66520b363e568bf2714b6d6bd63543dd4f79f8e834d40566656ea35d37ecdf2",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 2,
"duration_ms": 4436
},
{
"slug": "virtual-reality",
"status": "success",
"action": "new",
"event_id": "28b75d85774056e6e59097ebf58c44c9e8badadbd25084d0eebc3f95a9a90439",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [
"wss://relay.damus.io/"
],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 7,
"duration_ms": 14102
},
{
"slug": "wordpress-werkstatt",
"status": "success",
"action": "new",
"event_id": "5ff5ca9dcc4098e042c7bfe0808f05aea6d7ea871dbb69696594a7d7682b3115",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 7,
"duration_ms": 9300
},
{
"slug": "bibelfussball",
"status": "success",
"action": "new",
"event_id": "ee019e772f2c8e52a3d77041495508f5d65ab34bb46ccaff3a74f34173cc194f",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [
"wss://relay.damus.io/"
],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 1,
"duration_ms": 8627
},
{
"slug": "moodle-iomad-linux",
"status": "success",
"action": "new",
"event_id": "707e98d43993778d8e5ffb87a29262d32decc6d0518399991ed7e6c7b4dedf1d",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [
"wss://relay.damus.io/"
],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 4,
"duration_ms": 10653
},
{
"slug": "ob-virtualcam",
"status": "success",
"action": "new",
"event_id": "1e62cf1f6375fc3988024a3f3d02c041a3065ad69f53b32cb085858fff3c8ed0",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 31,
"duration_ms": 32549
},
{
"slug": "jojos-schoko-zimt-schnecken",
"status": "success",
"action": "new",
"event_id": "8561994ee97ebec8775e60106cd8c58b3b24df27c8c0f442e03c2ad384003df2",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 6,
"duration_ms": 9240
},
{
"slug": "gleichnis-vom-saemann",
"status": "success",
"action": "new",
"event_id": "a9b49cbf601b7dba4cb7b63e26c281308fee2f722b4c2c0bb7485d339cd9364e",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 9,
"duration_ms": 14335
},
{
"slug": "dampfnudeln",
"status": "success",
"action": "new",
"event_id": "51e032c62bc228ace874321f4bcb4f872fe4841258aba3d6d7208ce476664b43",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 6,
"duration_ms": 8128
},
{
"slug": "wordpress-statt-padlet-oder-taskcards",
"status": "success",
"action": "update",
"event_id": "8bd17088cb93d4b9868ac4764057f1963c9878a88abc528152649ff2d7b425ef",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 3,
"duration_ms": 7449
},
{
"slug": "offenheit-das-wesentliche",
"status": "success",
"action": "update",
"event_id": "45472a71074ed5fdb2c654c8500a9fb581bcf48221be3d565fb6b9ea088c9ab0",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [
"wss://relay.damus.io/"
],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 1,
"duration_ms": 8235
},
{
"slug": "bottomup-markdown",
"status": "success",
"action": "update",
"event_id": "a8030ba0f9c62a5787a84c607b11f3eaf9c0c694adcc245b9969522cff9f0e30",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [
"wss://relay.damus.io/"
],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 1,
"duration_ms": 7143
},
{
"slug": "kibedenken-bewusstsein",
"status": "success",
"action": "update",
"event_id": "a05458fc79f4192fef6902e8c55c465f3d9c26cede5772259d5f2d5879734dd2",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 1,
"duration_ms": 5147
},
{
"slug": "dezentrale-oep-oer",
"status": "success",
"action": "update",
"event_id": "4db003fd8c144fe1b0528c8cfbfb075ff6a8f203fd327c10c4c46e42fcac2a40",
"relays_ok": [
"wss://nos.lol/",
"wss://relay.primal.net/",
"wss://relay.tchncs.de/",
"wss://relay.edufeed.org/"
],
"relays_failed": [
"wss://relay.damus.io/"
],
"blossom_servers_ok": [
"https://blossom.edufeed.org",
"https://blossom.primal.net"
],
"images_uploaded": 1,
"duration_ms": 8489
}
],
"exit_code": 0
}

View File

@ -0,0 +1,602 @@
# Redaktion: Bild-Metadaten-Durchgang
**Zweck:** 91 Bilder in 18 Posts visuell prüfen und Alt-Texte, Lizenzen, Autor:innen-Angaben gegen das echte Bild abgleichen.
**Arbeitsweise:**
- Pro Bild: Checkbox `[ ]``[x]` wenn geprüft.
- Im **NOTIZ**-Feld: freie Änderungswünsche, Korrekturen, Klarstellungen.
- Bei `UNKNOWN`: Recherche-Ergebnis eintragen oder „bleibt UNKNOWN".
- Ich mache den Abgleich am Ende und schreibe alle Änderungen ins Frontmatter zurück.
**Links:**
- `📝 Frontmatter` → öffnet `index.md` zum Direkt-Editieren
- `🖼 Bild` → öffnet die Bilddatei lokal im Finder/Preview (mit `file://`)
---
## 2013-02-07 — premium-freemium-mium-mium-mium
📝 [Frontmatter](../content/posts/2013-02-07-premium-freemium-mium-mium-mium/index.md)
- [X] **my-very-hungry-caterpillar.jpg** (Cover, FREMD)
🖼 [Bild](../content/posts/2013-02-07-premium-freemium-mium-mium-mium/my-very-hungry-caterpillar.jpg)
Alt: „Kleine Raupe aus Papier gefaltet, Anspielung auf das Kinderbuch 'Die kleine Raupe Nimmersatt'"
Lizenz: CC BY-NC-SA 3.0 · Autor: Relly Annett-Baker · Quelle: flickr.com/photos/fizzkitten/4454153264
**NOTIZ:**
Bild ist leider nicht mehr verfügbar online - ich hoffe jedoch es gibt keine Abmahnung weil nicht mehr nachweisbar das cc-lizenz
---
## 2013-05-29 — erlebnispadagogik-im-handbuch-jugend-evangelische-perspektive
📝 [Frontmatter](../content/posts/2013-05-29-erlebnispadagogik-im-handbuch-jugend-evangelische-perspektive/index.md)
_Keine lokalen Bilder. Body enthält tote Amazon-Hotlinks zu Buchcovern (Affiliate-Programm 2018 eingestellt)._
- [X] **Amazon-Hotlinks entfernen / durch Text ersetzen?** Entscheidung später.
**NOTIZ:**
Ja Hotlink entfernen
---
## 2017-10-23 — telegram-octopi
📝 [Frontmatter](../content/posts/2017-10-23-telegram-octopi/index.md)
- [ ] **octopi1.png** (Cover)
🖼 [Bild](../content/posts/2017-10-23-telegram-octopi/octopi1.png)
Alt: „Screenshot der OctoPrint-Plugin-Verwaltung während der Installation des Telegram-Plugins — Fortschrittsanzeige läuft"
**NOTIZ:**
- [ ] **octopi2.png**
🖼 [Bild](../content/posts/2017-10-23-telegram-octopi/octopi2.png)
Alt: „Screenshot der Konfigurationsmaske des OctoPrint-Telegram-Plugins mit Eingabefeld für den Telegram-Bot-Token"
**NOTIZ:**
- [ ] **octopi3.png**
🖼 [Bild](../content/posts/2017-10-23-telegram-octopi/octopi3.png)
Alt: „Screenshot der OctoPrint-Telegram-Plugin-Oberfläche nach erfolgreichem Token-Eintrag — Benutzerliste wird angezeigt, Rechte fehlen noch"
**NOTIZ:**
- [ ] **octopi4.png**
🖼 [Bild](../content/posts/2017-10-23-telegram-octopi/octopi4.png)
Alt: „Screenshot der Benutzer-Rechte-Konfiguration mit gesetzten Häkchen bei 'Command' und 'Notify'"
**NOTIZ:**
Alle CC0
---
## 2017-10-31 — lutherkuerbis
📝 [Frontmatter](../content/posts/2017-10-31-lutherkuerbis/index.md)
- [ ] **kuerbis-titelbild.jpg** (Cover)
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/kuerbis-titelbild.jpg)
Alt: „Fertig geschnitzter Kürbis mit dem Muster einer Lutherrose, innen beleuchtet — glüht warm in der Dunkelheit"
**NOTIZ:**
- [ ] **lutherrose.png** (Vektorschablone, Vorlage aus dem Web)
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/lutherrose.png)
Alt: „Schwarz-weiße Vektorgrafik der Lutherrose: Kreuz im Herzen, umgeben von fünfblättriger Rose in einem Ring — als Schnitzschablone aufbereitet"
Caption: „Vektorisierte Schablone, abgeleitet von einer Fotovorlage aus dem Web"
Modifications: „Vektorisierung per online-convert.com aus gemeinfreier Fotovorlage; Originalurheber der Fotovorlage unbekannt"
**NOTIZ:**
- [ ] **kuerbis-aufschneiden.jpg**
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/kuerbis-aufschneiden.jpg)
Alt: „Hände schneiden mit großem Messer den Deckel von einem orangenen Kürbis ab"
**NOTIZ:**
- [ ] **kuerbis-entkernen.jpg**
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/kuerbis-entkernen.jpg)
Alt: „Mit einem Löffel wird das Fruchtfleisch und die Kerne aus dem Inneren des aufgeschnittenen Kürbis herausgekratzt"
**NOTIZ:**
- [ ] **schablone-aufbringen.jpg**
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/schablone-aufbringen.jpg)
Alt: „Papier-Schablone mit Lutherrosen-Motiv wird auf die Außenhaut des entkernten Kürbis geklebt"
**NOTIZ:**
- [ ] **kuerbis-ausschneiden.jpg**
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/kuerbis-ausschneiden.jpg)
Alt: „Mit einem Schnitzwerkzeug wird die Lutherrose entlang der Schablone aus der Kürbishaut herausgeschnitten"
**NOTIZ:**
Alle CC0
---
## 2019-03-26 — Pflanzenschild-QR-Code
📝 [Frontmatter](../content/posts/2019-03-26-Pflanzenschild-QR-Code/index.md)
- [ ] **cura-plugin-change-filment-at-z.png** (Cover)
🖼 [Bild](../content/posts/2019-03-26-Pflanzenschild-QR-Code/cura-plugin-change-filment-at-z.png)
Alt: „Screenshot des Cura-Slicers mit aktiviertem 'Change Filament at Z'-Plugin — Konfiguration eines Filamentwechsels in bestimmten Layern"
**NOTIZ:**
- [ ] **qr-code-pflanzenschild.jpg**
🖼 [Bild](../content/posts/2019-03-26-Pflanzenschild-QR-Code/qr-code-pflanzenschild.jpg)
Alt: „Dreieckiges 3D-gedrucktes Pflanzenschild mit aufgedrucktem zweifarbigem QR-Code, steckt in einem Pflanztopf"
**NOTIZ:**
Alle CC0
---
## 2021-08-15 — virtual-reality (⚠️ 4× UNKNOWN zur Recherche)
📝 [Frontmatter](../content/posts/2021-08-15-virtual-reality/index.md)
- [ ] **04-aframe.jpg** (Cover, EIGEN)
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/04-aframe.jpg)
Alt: „Screenshot einer A-Frame-WebVR-Szene: 3D-Objekte in einem Browser-Viewport, erstellt mit A-Frame-Framework"
Quelle: codepen.io/joerglohrer/full/dyXQqWG
**NOTIZ:**
- [ ] **01-immersion-wikipedia.jpg** (⚠️ UNKNOWN)
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/01-immersion-wikipedia.jpg)
Alt: „Screenshot des Wikipedia-Artikels 'Immersive learning' mit Einstiegsdefinition"
Lizenz: UNKNOWN · Autor: UNKNOWN · Quelle: en.wikipedia.org/wiki/Immersive_learning
Wikipedia-Text ist CC BY-SA — soll ich das so setzen?
**NOTIZ:**
- [ ] **02-mittelalterliche-kirche.jpg** (FREMD, Sketchfab)
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/02-mittelalterliche-kirche.jpg)
Alt: „Screenshot eines 3D-Modells einer mittelalterlichen Kirche (Calatrava la Nueva, Spanien) auf Sketchfab, erstellt aus 76 Laser-Scans und 4100 Fotos"
Lizenz: CC BY-NC 4.0 · Autor: UNKNOWN · Quelle: sketchfab.com/3d-models/medieval-church-…
Im Post-Body Zeile 120122 genannt: „Processed in Reality Capture from 76 Faro laser scans and 4100 photographs" — aber kein Urhebername. Recherche möglich?
**NOTIZ:**
- [ ] **03-avatare-erstellen.jpg** (⚠️ UNKNOWN, Ready Player Me)
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/03-avatare-erstellen.jpg)
Alt: „Screenshot der Avatar-Erstellung im Ready Player Me Web-Interface"
Lizenz: UNKNOWN · Autor: UNKNOWN
**NOTIZ:**
- [ ] **05-pupillendistanz.jpg** (⚠️ UNKNOWN, EyeMeasure-App)
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/05-pupillendistanz.jpg)
Alt: „Screenshot der iOS-App 'EyeMeasure' bei der Messung des Pupillenabstands mittels iPhone-Kamera"
Lizenz: UNKNOWN · Autor: UNKNOWN
**NOTIZ:**
- [ ] **06-vr-adapter-3ddruck.jpg** (EIGEN)
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/06-vr-adapter-3ddruck.jpg)
Alt: „3D-gedruckter Adapter zur Befestigung einer VIVE Deluxe Audio Strap an der Oculus Quest 2, frisch aus dem 3D-Drucker"
**NOTIZ:**
- [ ] **07-vive-straps-3ddruck.jpg** (EIGEN)
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/07-vive-straps-3ddruck.jpg)
Alt: „3D-gedruckte Halterungen der VIVE Deluxe Audio Strap, montiert an der Oculus Quest 2"
**NOTIZ:**
---
## 2021-11-17 — WordPress-Werkstatt
📝 [Frontmatter](../content/posts/2021-11-17-WordPress-Werkstatt/index.md)
- [ ] **04-termine-neu.png** (Cover)
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/04-termine-neu.png)
Alt: „Screenshot der WordPress-Beitragsübersicht mit eingefügtem Shortcode [relilab_termine], der eine Terminliste als Block rendert"
**NOTIZ:**
- [ ] **01-json-import.png**
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/01-json-import.png)
Alt: „Screenshot der ACF-Plugin-Oberfläche beim Import einer JSON-Datei mit Feldgruppen-Definitionen"
(Hinweis: im Body fälschlich `![](h01-json-import.png)` mit Tippfehler — Body-Fix später)
**NOTIZ:**
- [ ] **02-terminfelder.png**
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/02-terminfelder.png)
Alt: „Screenshot eines WordPress-Beitrags mit zwei neuen ACF-Terminfeldern 'Startet am' und 'Endet am' als Datum-/Zeit-Picker"
**NOTIZ:**
- [ ] **03-kategorien.png**
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/03-kategorien.png)
Alt: „Screenshot der WordPress-Kategorieverwaltung mit neu angelegter Kategorie 'Termine' samt Unterkategorien"
**NOTIZ:**
- [ ] **05-php-storm.png**
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/05-php-storm.png)
Alt: „Screenshot der PhpStorm-IDE mit geöffneter PHP-Datei zum add_shortcode()-Aufruf"
**NOTIZ:**
- [ ] **06-termine-listen.png**
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/06-termine-listen.png)
Alt: „Screenshot des PHP-Codes für die Funktion 'termineAusgeben' mit get_posts()-Abfrage und Shortcode-Registrierung"
**NOTIZ:**
- [ ] **07-external-library.png**
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/07-external-library.png)
Alt: „Screenshot der PhpStorm-Konfiguration zur Einbindung von WordPress als External Library für Auto-Complete"
**NOTIZ:**
---
## 2021-12-03 — bibelfussball
📝 [Frontmatter](../content/posts/2021-12-03-bibelfussball/index.md)
- [ ] **bibelfussball1.png** (Cover)
🖼 [Bild](../content/posts/2021-12-03-bibelfussball/bibelfussball1.png)
Alt: „Tafel-Skizze eines Fußballfeldes mit Mittellinie, Strafräumen und zwei Toren — Magnetknopf markiert die aktuelle Ballposition"
**NOTIZ:**
---
## 2022-02-16 — Moodle-Iomad-Linux
📝 [Frontmatter](../content/posts/2022-02-16-Moodle-Iomad-Linux/index.md)
- [ ] **title-gif.gif** (Cover)
🖼 [Bild](../content/posts/2022-02-16-Moodle-Iomad-Linux/title-gif.gif)
Alt: „Animiertes Titelbild des Artikels zur Moodle-Server-Installation mit Iomad unter Ubuntu"
**NOTIZ:**
- [ ] **01-netzwerkbruecke.png**
🖼 [Bild](../content/posts/2022-02-16-Moodle-Iomad-Linux/01-netzwerkbruecke.png)
Alt: „Screenshot der VirtualBox-Netzwerkeinstellungen mit aktivierter Netzwerkbrücke für die Ubuntu-VM"
**NOTIZ:**
- [ ] **02-hosts-eintragen.png**
🖼 [Bild](../content/posts/2022-02-16-Moodle-Iomad-Linux/02-hosts-eintragen.png)
Alt: „Terminal-Screenshot mit geöffneter /etc/hosts-Datei im nano-Editor, neuer Eintrag 'moodle.local' wird hinzugefügt"
**NOTIZ:**
- [ ] **03-config generieren.png** (Datei mit Leerzeichen im Namen!)
🖼 [Bild](<../content/posts/2022-02-16-Moodle-Iomad-Linux/03-config generieren.png>)
Alt: „Screenshot des Moodle-Installationsassistenten beim automatischen Generieren der config.php"
**NOTIZ:**
---
## 2022-03-19 — OB-virtualcam (31 Bilder)
📝 [Frontmatter](../content/posts/2022-03-19-OB-virtualcam/index.md)
- [ ] **29-autostartordner.jpg** (Cover)
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/29-autostartordner.jpg)
Alt: „Screenshot des Windows-Autostart-Ordners mit verknüpften OBS- und Zoom-Startlinks für automatischen Start beim Systemstart"
**NOTIZ:**
- [ ] **01-deutsche-tastatur-ubuntu.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/01-deutsche-tastatur-ubuntu.png)
Alt: „Screenshot der Ubuntu-Terminal-Dialog zur Konfiguration der deutschen Tastatur via dpkg-reconfigure"
**NOTIZ:**
- [ ] **02-chrome-remote-desktop.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/02-chrome-remote-desktop.png)
Alt: „Screenshot der Chrome-Remote-Desktop-Installation im Ubuntu-Terminal"
**NOTIZ:**
- [ ] **03-status-chrome-remote.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/03-status-chrome-remote.png)
Alt: „Screenshot des systemctl-Status des chrome-remote-desktop-Dienstes als 'active (running)'"
**NOTIZ:**
- [ ] **04-remotezugriff.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/04-remotezugriff.png)
Alt: „Screenshot der Chrome-Remote-Desktop-Konfigurationsseite mit SSH-Befehl und PIN-Eingabe"
**NOTIZ:**
- [ ] **05-systemctl-status.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/05-systemctl-status.png)
Alt: „Screenshot der systemctl-status-Ausgabe für chrome-remote-desktop mit aktivem Dienst"
**NOTIZ:**
- [ ] **06-cannot-open-video-device.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/06-cannot-open-video-device.png)
Alt: „Terminal-Screenshot der Fehlermeldung 'Cannot open device /dev/video0' bei v4l2-ctl --list-devices"
**NOTIZ:**
- [ ] **07-jetzt-v412-ctl.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/07-jetzt-v412-ctl.png)
Alt: „Terminal-Screenshot der erfolgreichen v4l2-ctl-Geräteliste nach Installation von v4l2loopback"
**NOTIZ:**
- [ ] **08-dummy-video-device.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/08-dummy-video-device.png)
Alt: „Terminal-Screenshot nach Reboot: virtuelle Kamera fehlt, Dummy-Video-Device muss neu geladen werden"
**NOTIZ:**
- [ ] **09-relilab-technical-host.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/09-relilab-technical-host.png)
Alt: „Screenshot der Chrome-Remote-Desktop-Geräteübersicht mit dem VM-Eintrag 'relilab-technical-host'"
**NOTIZ:**
- [ ] **10-pin-remote-desktop.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/10-pin-remote-desktop.png)
Alt: „Screenshot des Chrome-Remote-Desktop-PIN-Eingabefelds für die Remote-Verbindung"
**NOTIZ:**
- [ ] **11-keyboard-tastatur-umstellen.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/11-keyboard-tastatur-umstellen.png)
Alt: „Screenshot der Linux-Keyboard-Einstellungen mit Umstellung auf deutsche Tastaturbelegung"
**NOTIZ:**
- [ ] **12-apps-verknuepfen.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/12-apps-verknuepfen.png)
Alt: „Screenshot der Cinnamon-Desktop-Umgebung mit Drag-and-Drop-Verknüpfung von Anwendungen auf den Desktop"
**NOTIZ:**
- [ ] **13-startvirtualcam.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/13-startvirtualcam.png)
Alt: „Screenshot der OBS-Verknüpfung mit dem Zusatzparameter --startvirtualcam im Startbefehl"
**NOTIZ:**
- [ ] **14-OBS-deutsch-umstellen.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/14-OBS-deutsch-umstellen.png)
Alt: „Screenshot der OBS-Studio-Einstellungen beim Umschalten der Benutzeroberfläche auf Deutsch"
**NOTIZ:**
- [ ] **15-obs-mit-virtual-cam-starten.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/15-obs-mit-virtual-cam-starten.png)
Alt: „Screenshot der OBS-Startbefehl-Konfiguration mit --startvirtualcam-Parameter für automatischen Kamera-Start"
**NOTIZ:**
- [ ] **16-startup-application.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/16-startup-application.png)
Alt: „Screenshot der Cinnamon-Startup-Applications-Verwaltung mit neu hinzugefügtem OBS-Eintrag"
**NOTIZ:**
- [ ] **17-i-will-only-be-using-OBS.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/17-i-will-only-be-using-OBS.png)
Alt: „Screenshot des OBS-Auto-Configuration-Wizard mit ausgewählter Option 'I will only be using the virtual camera'"
**NOTIZ:**
- [ ] **18-video1920.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/18-video1920.png)
Alt: „Screenshot der OBS-Video-Einstellungen mit Auflösung 1920x1080"
**NOTIZ:**
- [ ] **19-szenensammlung-importieren-OBS.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/19-szenensammlung-importieren-OBS.png)
Alt: „Screenshot des OBS-Menüs 'Szenensammlung importieren' mit Auswahl einer JSON-Datei"
**NOTIZ:**
- [ ] **20-chrome-einrichten.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/20-chrome-einrichten.png)
Alt: „Screenshot des Ubuntu-Keyring-Passwort-Dialogs beim ersten Chrome-Start"
**NOTIZ:**
- [ ] **21-chrome-standard.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/21-chrome-standard.png)
Alt: „Screenshot der Google-Chrome-Einstellungen mit gesetzter Option 'Als Standardbrowser festlegen'"
**NOTIZ:**
- [ ] **22-chrome-anmeldung.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/22-chrome-anmeldung.png)
Alt: „Screenshot der Google-Account-Anmeldung in Chrome mit aktiviertem Sync"
**NOTIZ:**
- [ ] **23-zoom-anmeldung.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/23-zoom-anmeldung.png)
Alt: „Screenshot der Zoom-Client-Anmeldemaske unter Linux"
**NOTIZ:**
- [ ] **24-zoom-sprache-aendern.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/24-zoom-sprache-aendern.png)
Alt: „Screenshot des Zoom-Tray-Menüs mit Sprachauswahl-Untermenü zur Umstellung auf Deutsch"
**NOTIZ:**
- [ ] **25-slides-emojis.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/25-slides-emojis.png)
Alt: „Screenshot einer Präsentationsfolie im Chrome-Browser mit fehlenden Emoji-Zeichen als leere Platzhalter"
**NOTIZ:**
- [ ] **26-keyring-problem.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/26-keyring-problem.png)
Alt: „Screenshot der Ubuntu-GUI-Fehlermeldung beim Versuch, sich als Root einzuloggen"
**NOTIZ:**
- [ ] **27-startvirtualcam-verknuepft-OBS.jpg**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/27-startvirtualcam-verknuepft-OBS.jpg)
Alt: „Screenshot der Windows-Eigenschaften einer OBS-Desktop-Verknüpfung mit --startvirtualcam-Parameter"
**NOTIZ:**
- [ ] **28-shell-startup.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/28-shell-startup.png)
Alt: „Screenshot des Windows-Run-Dialogs mit Befehl 'shell:startup' zum Öffnen des Autostart-Ordners"
**NOTIZ:**
- [ ] **v412-ctl-fehlermeldung.png**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/v412-ctl-fehlermeldung.png)
Alt: „Terminal-Screenshot der v4l2-ctl-Fehlermeldung beim Öffnen des Video-Gerätes"
**NOTIZ:**
- [ ] **virtueller-desktop-titelbild.jpg**
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/virtueller-desktop-titelbild.jpg)
Alt: „Stilisiertes Titelbild: virtueller Desktop-Arbeitsplatz mit mehreren Bildschirmen und Remote-Verbindung"
**NOTIZ:**
---
## 2023-02-26 — jojos-schoko-zimt-schnecken
📝 [Frontmatter](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/index.md)
- [ ] **schneckennudeln-titel.jpg** (Cover)
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/schneckennudeln-titel.jpg)
Alt: „Goldbraun gebackene Hefeschnecken in einer Kuchenform, Titelbild des Rezepts"
**NOTIZ:**
- [ ] **Hefeteig-mit-Fuellung.jpg**
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/Hefeteig-mit-Fuellung.jpg)
Alt: „Ausgerollter Hefeteig, bestrichen mit cremiger Kakao-Zimt-Zucker-Füllung, bereit zum Einrollen"
**NOTIZ:**
- [ ] **16-Schneckennudeln.jpg**
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/16-Schneckennudeln.jpg)
Alt: „16 dicht an dicht aufgestellte, rohe Hefeschnecken in einer runden Kuchenform"
**NOTIZ:**
- [ ] **hefeschnecken-in-capelle-backform.jpg**
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/hefeschnecken-in-capelle-backform.jpg)
Alt: „Gegangene, mit Eimilch bestrichene Hefeschnecken in Kapellen-Backform, bereit für den Ofen"
**NOTIZ:**
- [ ] **schneckennudeln-im-ofen.jpg**
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/schneckennudeln-im-ofen.jpg)
Alt: „Hefeschnecken im Ofen während des Backens, Oberseite beginnt goldbraun zu werden"
**NOTIZ:**
- [ ] **schneckennudeln-fertig.jpg**
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/schneckennudeln-fertig.jpg)
Alt: „Fertig gebackene, goldbraune Hefeschnecken in der Kuchenform, bereit zum Servieren"
**NOTIZ:**
---
## 2023-03-23 — saemann (Midjourney, CC BY-SA 3.0 DE)
📝 [Frontmatter](../content/posts/2023-03-23-saemann/index.md)
- [ ] **saemann-title.jpg** (Cover, Collage)
🖼 [Bild](../content/posts/2023-03-23-saemann/saemann-title.jpg)
Alt: „Titelbild zum Gleichnis vom Sämann: Collage der fünf KI-generierten Illustrationen im Stil von Eric Carle"
Modifications: „Collage aus Midjourney-generierten Bildern im Stil von Eric Carle, Prompts siehe Artikel"
**NOTIZ:**
- [ ] **bild1-saemann.jpeg**
🖼 [Bild](../content/posts/2023-03-23-saemann/bild1-saemann.jpeg)
Alt: „Illustration im Stil von Eric Carle: Ein freundlicher Bauer streut Samen in einem offenen Feld, im Hintergrund vier Böden — felsig, dornig, vogelreich und fruchtbar"
**NOTIZ:**
- [ ] **bild1-alternativ-saemann.jpeg**
🖼 [Bild](../content/posts/2023-03-23-saemann/bild1-alternativ-saemann.jpeg)
Alt: „Alternative Illustration im Stil von Eric Carle: Bauer beim Säen mit verschiedenen Bodenarten im Hintergrund"
**NOTIZ:**
- [ ] **bild2-saemann.jpeg**
🖼 [Bild](../content/posts/2023-03-23-saemann/bild2-saemann.jpeg)
Alt: „Illustration im Stil von Eric Carle: Kleine, schwache Pflanzen, die mit wenig Erde auf felsigem Boden zu wachsen beginnen"
**NOTIZ:**
- [ ] **bild2-alternativ-saemann.jpeg**
🖼 [Bild](../content/posts/2023-03-23-saemann/bild2-alternativ-saemann.jpeg)
Alt: „Alternative Illustration im Stil von Eric Carle: Keimende Pflanzen auf steinigem Grund"
**NOTIZ:**
- [ ] **bild3-saemann.jpeg**
🖼 [Bild](../content/posts/2023-03-23-saemann/bild3-saemann.jpeg)
Alt: „Illustration im Stil von Eric Carle: Junge Pflanzen werden von Dornen umklammert und erstickt"
**NOTIZ:**
- [ ] **bild4-saemann.jpeg**
🖼 [Bild](../content/posts/2023-03-23-saemann/bild4-saemann.jpeg)
Alt: „Illustration im Stil von Eric Carle: Fröhliche Vögel picken Samen vom Boden und fressen sie, bevor sie keimen können"
**NOTIZ:**
- [ ] **bild5-saemann.jpeg**
🖼 [Bild](../content/posts/2023-03-23-saemann/bild5-saemann.jpeg)
Alt: „Illustration im Stil von Eric Carle: Große, gesunde Pflanzen tragen reiche Früchte auf fruchtbarem Boden, der Bauer steht lächelnd daneben"
**NOTIZ:**
- [ ] **screen-chatgpt-saemann.png**
🖼 [Bild](../content/posts/2023-03-23-saemann/screen-chatgpt-saemann.png)
Alt: „Screenshot des ChatGPT-Dialogs: Eingabe der Anfrage zum Gleichnis vom Sämann für einen 8-Jährigen und KI-generierte Antwort in fünf Bildbeschreibungen"
**NOTIZ:**
---
## 2023-04-07 — Dampfnudeln
📝 [Frontmatter](../content/posts/2023-04-07-Dampfnudeln/index.md)
- [ ] **Hefefreuden.jpg** (Cover)
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Hefefreuden.jpg)
Alt: „Titelbild: Dampfnudeln und Hefezopf auf einem Tisch, frisch aus Dampfgarer und Ofen"
**NOTIZ:**
- [ ] **Hefeteig.jpg**
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Hefeteig.jpg)
Alt: „Aufgegangener Hefeteig in einer Rührschüssel, glatt und elastisch, nach 30 Minuten Ruhezeit"
**NOTIZ:**
- [ ] **Dampfnudeln-auf-Lochblech.jpg**
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Dampfnudeln-auf-Lochblech.jpg)
Alt: „Sechs runde Hefeteigstücke zum Dampfgaren auf einem gelochten Dampfgarblech"
**NOTIZ:**
- [ ] **Dampfnudeln-im-Dampfgarer.jpg**
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Dampfnudeln-im-Dampfgarer.jpg)
Alt: „Gegarte, aufgegangene Dampfnudeln im geöffneten Dampfgarer, glänzend und flaumig"
**NOTIZ:**
- [ ] **Dampfnudel-mit-Vanillesosse.jpg**
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Dampfnudel-mit-Vanillesosse.jpg)
Alt: „Dampfnudel auf Teller angerichtet, übergossen mit goldgelber Vanillesoße"
**NOTIZ:**
- [ ] **Hefezopf.jpg**
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Hefezopf.jpg)
Alt: „Frisch gebackener, dreifach geflochtener Hefezopf, goldbraun glänzend nach dem Einpinseln mit Ei"
**NOTIZ:**
---
## 2023-07-25 — wordpress-statt-padlet-oder-taskcards
📝 [Frontmatter](../content/posts/2023-07-25-wordpress-statt-padlet-oder-taskcards/index.md)
- [ ] **wordpress-horizontales-scrollen.gif** (Cover)
🖼 [Bild](../content/posts/2023-07-25-wordpress-statt-padlet-oder-taskcards/wordpress-horizontales-scrollen.gif)
Alt: „Animierter Screenshot: WordPress-Seite mit horizontal scrollbaren Spalten, die Beiträge im Kanban-Stil nebeneinander zeigen"
**NOTIZ:**
- [ ] **spalten-als-posts-block.png**
🖼 [Bild](../content/posts/2023-07-25-wordpress-statt-padlet-oder-taskcards/spalten-als-posts-block.png)
Alt: „Screenshot des Stackable 'posts block'-Plugins in WordPress mit Spaltenansicht nach Kategorien"
**NOTIZ:**
- [ ] **posts-per-drag-and-drop-sortieren.png**
🖼 [Bild](../content/posts/2023-07-25-wordpress-statt-padlet-oder-taskcards/posts-per-drag-and-drop-sortieren.png)
Alt: „Screenshot der WordPress-Beitragsliste mit aktiviertem 'Intuitive Custom Post Order'-Plugin — Beiträge werden per Drag & Drop sortiert"
**NOTIZ:**
---
## 2024-01-16 — offenheit-das-wesentliche (Midjourney, CC0)
📝 [Frontmatter](../content/posts/2024-01-16-offenheit-das-wesentliche/index.md)
- [ ] **offenheit-wesentlich.png** (Cover)
🖼 [Bild](../content/posts/2024-01-16-offenheit-das-wesentliche/offenheit-wesentlich.png)
Alt: „KI-generierte Aquarell-Illustration: Silhouetten von Menschen aller Geschlechter und Altersgruppen, die ineinander übergehen und sich überlappen — Symbol einer Community of Trust"
Modifications: „KI-generiert mit Midjourney v6.0, Prompt: A Community of Trust based on Openness, silhouettes of people of all genders and ages that merge into each other and overlap, watercolors --v 6.0 --seed 1235164279"
**NOTIZ:**
---
## 2024-03-05 — bottomup-markdown
📝 [Frontmatter](../content/posts/2024-03-05-bottomup-markdown/index.md)
- [ ] **bottomup-markdown.png** (Cover)
🖼 [Bild](../content/posts/2024-03-05-bottomup-markdown/bottomup-markdown.png)
Alt: „Titelbild zur OER-Camp-Session 'BottomUp MarkDown' — Symbol für die 5V-Freiheiten von Open Content in Verbindung mit der Markdown-Sprache"
**NOTIZ:**
---
## 2024-04-03 — kibedenken-bewusstsein (Midjourney, CC0)
📝 [Frontmatter](../content/posts/2024-04-03-kibedenken-bewusstsein/index.md)
- [ ] **kibedenken.png** (Cover)
🖼 [Bild](../content/posts/2024-04-03-kibedenken-bewusstsein/kibedenken.png)
Alt: „Ein junger Roboterjunge mit gesenktem Kopf betrachtet seine Spiegelung im Wasser, im fotorealistischen Stil einer Canon EOS 5D Mark IV"
Caption: „Referenziert auf Narziss aus der griechischen Mythologie und die Illustration von Caravaggio (siehe [Wikipedia #Narziss](https://de.wikipedia.org/wiki/Narziss#))"
Modifications: „KI-generiert mit Midjourney v6.0, Prompt: photographed with the Canon EOS 5D Mark IV a young robot boy with his head down, looking at his reflection in water --v6.0"
**NOTIZ:**
---
## 2025-03-04 — dezentrale-oep-oer (3 Autoren, CC BY 4.0)
📝 [Frontmatter](../content/posts/2025-03-04-dezentrale-oep-oer/index.md)
- [ ] **dezentrale-oep-oer.png** (Cover)
🖼 [Bild](../content/posts/2025-03-04-dezentrale-oep-oer/dezentrale-oep-oer.png)
Alt: „Ein in den Sand gezeichneter Strauß mit den Buchstaben 'OER' — Sinnbild für offene Bildung und freien Wissensaustausch, gleichzeitig Wortspiel-Verbindung zu Nostr (Ostrich = Strauß)"
Caption: „Analog zum Ichthys-Fisch als geheimem Erkennungszeichen: Symbol einer Gemeinschaft, die Wissen offen, unabhängig und widerstandsfähig teilt"
Autoren: Jörg Lohrer, Steffen Rörtgen, Bastian Granas
**NOTIZ:**
---
## Globale Anmerkungen / Änderungswünsche
_Alles was nicht bildspezifisch ist (Lizenz-Defaults, Regeln für UNKNOWN, Generalvorschläge) kann hier rein:_
**NOTIZ:**
---
## Zusammenfassung
- **91 Bilder** in 18 Posts
- **1 Post** ohne lokale Bilder (Erlebnispädagogik, tote Amazon-Hotlinks)
- **4 UNKNOWN-Einträge** zur Recherche (alle im VR-Post)
- **1 Fremdbild** (Flickr, CC BY-NC-SA, Raupe)
- **1 teilfremdes Bild** (Sketchfab, CC BY-NC, Fotograf UNKNOWN)
- **Rest Eigenaufnahmen** (CC0 oder CC BY-SA)

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,10 @@
# Publish-Pipeline für Nostr-Events — Design-Spec
**Datum:** 2026-04-15
**Datum:** 2026-04-15 (aktualisiert 2026-04-16: Blossom für alle Bilder, kein All-Inkl-rsync-Pfad mehr)
**Status:** Entwurf, ausstehende User-Freigabe
**Scope:** Toolchain, die Markdown-Posts aus `content/posts/*/index.md` in signierte Nostr-Events (`kind:30023`, NIP-23) umwandelt, zu Relays publiziert, und die zugehörigen Bilder zum Asset-Host (All-Inkl für Altposts, Blossom für neue) hochlädt.
**Scope:** Toolchain, die Markdown-Posts aus `content/posts/*/index.md` in signierte Nostr-Events (`kind:30023`, NIP-23) umwandelt, zu Relays publiziert, und die zugehörigen Bilder zu Blossom hochlädt.
**Designentscheidung 2026-04-16:** Alle Bilder (auch die der 18 Altposts) werden zu Blossom hochgeladen. Kein rsync-Legacy-Pfad, kein `image_source`-Flag im Frontmatter. Die SPA rendert alle Posts über denselben Code-Pfad (Event-Text → Bild-URLs aus Blossom). Repo = Source-of-Truth für Content, Pipeline = Nostr-Export-Routine.
Diese Spec ist die Schwester-Spec zu [`2026-04-15-nostr-page-design.md`](2026-04-15-nostr-page-design.md) und teilt sich mit ihr den Event-Kontrakt für `kind:30023` und die Konfiguration über `kind:10002` / `kind:10063`.
@ -34,23 +36,22 @@ Diese Spec ist die Schwester-Spec zu [`2026-04-15-nostr-page-design.md`](2026-04
│ (Git-Diff oder force) │
│ 3. Pro Post: │
│ a. Frontmatter parsen │
│ b. Markdown transform │
│ c. Bilder upload │
│ (legacy/blossom) │
│ b. Bilder aus Ordner → │
│ Blossom upload │
│ c. Markdown body: bild- │
│ pfade → Blossom-URLs │
│ d. Event bauen │
│ e. Via NIP-46 signieren │
│ f. Zu Relays pushen │
└──────┬──────────────────────┘
┌──────────┼──────────────┬──────────────┐
▼ ▼ ▼ ▼
Amber Public Blossom- All-Inkl
(NIP-46 Nostr- Server (rsync
Signer Relays (primal, over SSH,
via aus später eigen) Altbilder
Relay) kind:10002 aus der 18
kind:10063) Migrations-
posts)
┌──────────┼──────────────┐
▼ ▼ ▼
Amber Public Blossom-
(NIP-46 Nostr- Server
Signer Relays aus kind:10063
via aus (primal,
Relay) kind:10002 später eigener)
```
### Kernprinzipien
@ -152,34 +153,14 @@ Einmalig manuell publizieren. Phase-1-Inhalt: ein Server.
Phase-5-Erweiterung (eigener Blossom-Server): zusätzliches `["server", "https://blossom.joerg-lohrer.de"]` wird vorne in die Liste aufgenommen, neues Event publiziert.
### 2.5 SSH-Deploy-Key für All-Inkl
1. Lokal Keypair erzeugen, **dediziert für Deploys**, nicht persönlicher SSH-Key:
```
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_joerglohrerde_deploy -C "deploy-joerglohrerde"
```
Ohne Passphrase (CI braucht non-interactive Zugang).
2. Public-Key-Inhalt (`*.pub`) in All-Inkl-KAS unter „SSH-Zugänge" → „Authorized Keys" eintragen.
3. Verbindung testen: `ssh -i ~/.ssh/id_ed25519_joerglohrerde_deploy w00xxxxx@ssh.all-inkl.com`
4. Private-Key bereitstellen:
- **Lokal:** liegt in `~/.ssh/` und wird von rsync automatisch genutzt.
- **CI:** als GitHub-Actions-Secret `SSH_DEPLOY_KEY` (Inhalt der privaten Key-Datei). Im Workflow wird er in `~/.ssh/id_ed25519` gechrieben und `chmod 600` gesetzt.
### 2.6 All-Inkl Deploy-Root
Nach Tarifwechsel auf Premium: Pfad im KAS unter „Dateiverwaltung" ablesen. Typisch: `w00xxxxx@ssh.all-inkl.com:joerg-lohrer.de/`.
- **Lokal:** in `.env` als `ALLINKL_DEPLOY_ROOT`
- **CI:** als GitHub-Actions-Secret
### 2.7 `deno task check`
### 2.5 `deno task check`
Dieser Subcommand verifiziert alle obigen Punkte:
- `BUNKER_URL` gesetzt, Bunker antwortet auf Ping, Pubkey stimmt mit `AUTHOR_PUBKEY_HEX` überein.
- `kind:10002` auf Bootstrap-Relay gefunden, mindestens 1 Relay eingetragen.
- `kind:10063` auf Bootstrap-Relay gefunden, mindestens 1 Server eingetragen.
- SSH-Verbindung zu `ALLINKL_DEPLOY_ROOT` erfolgreich (`ssh ... echo ok`).
- Blossom-Server aus `kind:10063` antwortet auf HEAD / (Healthcheck).
- Deno-Version und benötigte Permissions.
Bei jedem Fehler: klare Text-Meldung, was zu tun ist (z. B. „kind:10002 fehlt — publiziere es manuell mit folgendem Schema: ...").
@ -249,72 +230,47 @@ Slug kommt als **lowercase String** aus dem Frontmatter-Feld `slug:`. Ist bereit
### 4.2 Bild-URL-Transformation
Ziel: alle relativen Bild-Referenzen im Markdown-Body werden zu absoluten URLs.
Ziel: alle relativen Bild-Referenzen im Markdown-Body werden durch Blossom-URLs ersetzt. Ablauf:
**Erkannte Muster:**
- `![alt](filename)` — reguläre Markdown-Bild-Syntax.
- `[![alt](filename)](link)` — Bild-in-Link-Konstrukt.
- `![alt](filename =WxH)` — mit Größen-Suffix (Obsidian/PaperMod-Erweiterung).
1. Pipeline sammelt alle Bilder aus dem Post-Ordner (Datei-Scan nach gängigen Bild-Extensions).
2. Jedes Bild wird zu allen Servern aus `kind:10063` hochgeladen (siehe §5).
3. Blossom liefert eine hash-basierte URL zurück (Format: `<server>/<sha256>` oder `<server>/<sha256>.<ext>`).
4. Pipeline baut eine Mapping-Tabelle `<dateiname> → <blossom-url>`.
5. Markdown-Body wird traversiert, alle erkannten Bild-Patterns werden ersetzt:
- `![alt](filename.png)``![alt](<blossom-url>)`
- `[![alt](filename.png)](link)``[![alt](<blossom-url>)](link)`
- `![alt](filename.png =WxH)``![alt](<blossom-url>)` (Größen-Suffix entfernt; SPA skaliert per CSS)
6. Wenn `filename` bereits ein Schema enthält (`http://`, `https://`, `//`), bleibt die URL unverändert — ist schon absolut.
**Regeln:**
1. Wenn `filename` ein Schema enthält (`http://`, `https://`, `//`), nicht transformieren — ist schon absolut.
2. Ansonsten zu absoluter URL machen; URL-Kodierung pro Pfad-Segment via `encodeURIComponent()`.
3. `=WxH`-Suffix entfernen; die SPA skaliert Bilder per CSS responsiv.
**Konsequenz:** Es gibt nur **einen** Upload-Pfad (Blossom). Kein Legacy-Pfad mehr. Kein `image_source`-Flag, keine Datum-basierten URL-Strukturen.
**Basis-URL je nach `image_source`-Frontmatter:**
### 4.3 Cover-Image-Tag
- Wenn `image_source: legacy``https://joerg-lohrer.de/<YYYY>/<MM>/<DD>/<dtag>.html/<encoded-filename>`
- `YYYY/MM/DD` aus `date:`-Frontmatter, nicht aus dem Signatur-Zeitpunkt.
- `<dtag>` ist identisch mit `slug`.
- Wenn `image_source` fehlt oder `image_source: blossom` → Blossom-URL; siehe Abschnitt 5.
### 4.3 `image_source`-Flag
**Einmaliger Migrationsschritt (vor erstem Publish-Lauf):** Die 18 Altposts bekommen `image_source: legacy` ins Frontmatter geschrieben. Das ist ein separater Commit, kein Pipeline-Feature.
**Neue Posts:** kein Flag nötig, Default = `blossom`. Wenn ein zukünftiger Post explizit auf All-Inkl zeigen soll (außergewöhnlich), kann `image_source: legacy` gesetzt werden.
### 4.4 Cover-Image-Tag
Das `image`-Tag im Event (für Listen-Previews/OG-Vorschau in Nostr-Clients) kommt aus dem Frontmatter (nicht aus dem Markdown-Body):
Das `image`-Tag im Event (für Listen-Previews/OG-Vorschau in Nostr-Clients) kommt aus dem Frontmatter:
- Quelle: `cover.image:` (Hugo-Page-Bundle-Konvention); Fallback `image:` auf Top-Level.
- Ist typischerweise ein relativer Dateiname.
- Wird durch denselben URL-Bauer wie die Body-Bilder geschickt (Abschnitt 4.2), aber der Input ist ein direkter Dateiname aus YAML, nicht aus Markdown-Syntax. Keine `=WxH`-Suffix-Erkennung nötig.
- Ergebnis: absolute URL gemäß `image_source`-Policy.
- Ist typischerweise ein relativer Dateiname, der als Bild auch im Post-Ordner liegt und damit ohnehin zu Blossom hochgeladen wird.
- Wird nach dem Upload über die Mapping-Tabelle auf die Blossom-URL umgeschrieben.
- Wenn der Wert bereits absolut ist (http/https), bleibt er unverändert.
---
## 5. Upload-Pfade
## 5. Upload-Pfad
### 5.1 Legacy-Upload (All-Inkl)
### 5.1 Blossom-Upload (einheitlich für alle Posts)
Betrifft: die 18 Altposts, Bilder darin.
**Mechanik:** `rsync` over SSH via `Deno.Command("rsync", [...])`.
**Befehlsschema:**
```
rsync -avz --no-perms --no-times \
-e "ssh -i $DEPLOY_KEY_PATH -o StrictHostKeyChecking=accept-new" \
<post-folder>/*.{png,jpg,jpeg,gif,webp,svg} \
$ALLINKL_DEPLOY_ROOT<YYYY>/<MM>/<DD>/<dtag>.html/
```
- **Idempotent:** rsync überträgt nur neue/geänderte Dateien.
- **Nicht-löschend:** ohne `--delete`. Alte Bilder bleiben auf dem Server liegen, keine automatische Bereinigung. Manueller Aufräum-Bedarf wird hingenommen (Tote Dateien verursachen keinen Schaden, Storage ist billig).
- **Zielordner erzeugen:** rsync legt fehlende Ordner per `--mkpath` oder (wenn Version zu alt) per vorgeschaltetem `ssh ... mkdir -p` an.
**Neuer Post-Edit mit alten Bildern:** falls jemand mal einen Post editiert, der `image_source: legacy` hat und neue Bilder hinzufügt → diese werden auch zu All-Inkl geschoben. Das ist okay. Das Flag steuert nur den URL-Basispfad, nicht die Intention „nie wieder All-Inkl".
### 5.2 Blossom-Upload
Betrifft: alle neuen Posts (`image_source: blossom` oder fehlend).
Betrifft: alle Bilder aller Posts, ohne Unterscheidung zwischen Alt- und Neu-Post.
**Mechanik:** BUD-01 HTTP-Upload zu allen Servern aus `kind:10063`-Liste, parallel.
**Schritte pro Bild:**
**Ablauf pro Post:**
1. Alle Dateien im Post-Ordner mit Bild-Extensions (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`) sammeln.
2. Hugo-generierte Resize-Varianten (`*_hu_*.png` etc.) werden **ignoriert** — das sind Derivate, keine Originale. Nur die Originaldateien, wie sie im Markdown referenziert werden, zählen.
3. Pro Bild SHA-256 berechnen, zu allen Servern parallel hochladen.
4. Mapping `<filename> → <primary-blossom-url>` aufbauen (primär = erster Server aus Liste).
**Schritte pro Bild (intern):**
1. SHA256-Hash der Datei berechnen.
2. Authorization-Event (`kind:24242`) bauen und via Bunker signieren (enthält Hash, Verb `upload`, Expiration).
@ -329,6 +285,17 @@ Betrifft: alle neuen Posts (`image_source: blossom` oder fehlend).
**Retry:** 2 Versuche pro Server mit exponentiellem Backoff.
**Idempotenz:** Blossom dedupliziert per SHA-256. Ein erneuter Upload derselben Datei ist ein No-Op (Server antwortet 200 mit derselben URL). Daher ist wiederholtes `--force-all` unproblematisch.
### 5.2 Kein Legacy-Upload mehr
Frühere Versionen dieser Spec sahen einen rsync-Pfad zu All-Inkl für Altposts vor. Das ist entfallen. Begründung:
- Repo ist Source-of-Truth; alle Bilder liegen in `content/posts/<ordner>/`.
- Einheitlicher Render-Pfad in der SPA (keine Sonderlogik für Altposts).
- Blossom dedupliziert per Hash; wiederholter Upload ist billig.
- Nach Cutover verwaisen die alten `joerg-lohrer.de/YYYY/MM/DD/…`-URLs — das ist akzeptiert, da sie nur in der weggehenden Hugo-Site referenziert sind.
---
## 6. Change-Detection und Workflow
@ -406,13 +373,9 @@ Pro Post wird das signierte Event an alle Relays aus der `kind:10002`-Liste para
### 7.2 Blossom-Upload
Siehe Abschnitt 5.2. Pro Server 2 Retries, mindestens 1 Server muss akzeptieren.
Siehe Abschnitt 5.1. Pro Server 2 Retries, mindestens 1 Server muss akzeptieren.
### 7.3 Legacy-Upload
rsync-Aufruf wird bei Exit-Code != 0 einmal wiederholt (1 Retry, 3 s Pause). Bleibt der Aufruf fehlerhaft, wird der Post als failed markiert und die Pipeline fährt mit dem nächsten fort.
### 7.4 Bunker-Signing
### 7.3 Bunker-Signing
- Timeout 30 Sekunden pro Signatur-Request (Handy-Wake-up berücksichtigen).
- 1 Retry bei Timeout.
@ -470,7 +433,6 @@ publish/
│ │ ├── signer.ts # NIP-46 Bunker-Wrapper
│ │ ├── relays.ts # loadOutboxRelays, publishEvent
│ │ ├── blossom.ts # loadServerList, uploadBlob
│ │ ├── legacy-upload.ts # rsync SSH wrapper
│ │ ├── change-detection.ts # gitDiff, allPostFiles, forceMode
│ │ └── log.ts # structured logger + JSON writer
│ └── subcommands/
@ -548,25 +510,18 @@ jobs:
with:
deno-version: v2.x
- name: Setup SSH-Deploy-Key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan ssh.all-inkl.com >> ~/.ssh/known_hosts
- name: Pre-Flight Check
env:
BUNKER_URL: ${{ secrets.BUNKER_URL }}
ALLINKL_DEPLOY_ROOT: ${{ secrets.ALLINKL_DEPLOY_ROOT }}
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
run: deno task check
- name: Publish
env:
BUNKER_URL: ${{ secrets.BUNKER_URL }}
ALLINKL_DEPLOY_ROOT: ${{ secrets.ALLINKL_DEPLOY_ROOT }}
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
run: |
if [ "${{ inputs.force_all }}" = "true" ]; then
@ -594,7 +549,7 @@ Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppe
- `kind:30023` Event-Schema — Publish produziert, SPA konsumiert.
- `kind:10002` Relay-Liste — Publish liest, SPA liest.
- `kind:10063` Blossom-Liste — Publish liest beim Upload, SPA liest für Bild-Fallback (zukünftig).
- Bild-URL-Konvention für Altposts `/YYYY/MM/DD/<dtag>.html/<file>` — Publish schreibt, SPA erwartet.
- Alle Bild-URLs zeigen auf Blossom (hash-basiert) — einheitlich für alle Posts.
**Unabhängige Entwicklung möglich:**
@ -604,7 +559,7 @@ Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppe
**Abhängigkeit beim Cutover (SPA-Migrationsschritte C + D):**
- SPA kann erst live gehen, wenn die 18 Altposts als Events auf Relays liegen.
- **Schritt C** der SPA-Migration bedeutet konkret: einmaliger lokaler Lauf `deno task publish --force-all` mit dem vollständigen Altbestand. Dieser Schritt liegt zeitlich **vor** Schritt D (dem tatsächlichen Cutover auf All-Inkl).
- **Schritt C** der SPA-Migration bedeutet konkret: einmaliger lokaler Lauf `deno task publish --force-all` mit dem vollständigen Altbestand. Dieser Schritt liegt zeitlich **vor** Schritt D (dem tatsächlichen Cutover der Hauptdomain).
- Voraussetzung ist, dass die Publish-Pipeline zu diesem Zeitpunkt vollständig implementiert und durch `deno task check` validiert ist.
**Laufender Betrieb:**
@ -621,12 +576,11 @@ Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppe
|---|---|---|---|
| Amber offline während CI | mittel | hoch (Pipeline bricht ab) | Clear Error; Nutzer retriggert manuell nachdem Handy verfügbar |
| Bunker-Secret leakt (Repo-Secret) | niedrig | mittel | Secret rotierbar: in Amber Pairing löschen, neu pairen, Secret aktualisieren |
| SSH-Deploy-Key leakt | niedrig | mittel | Dedicated Key, in All-Inkl-KAS revokebar |
| `kind:10002` versehentlich überschrieben (Relay-Liste leer) | niedrig | hoch | check-Subcommand prüft vor jedem Run; Pipeline bricht bei leerer Liste ab |
| Relay-Zensur (Events werden gelöscht) | niedrig | mittel | Multi-Relay-Push; zusätzlich bezahltes nostr.wine als Durability-Anker |
| Git-Diff übersieht Post (Rebase, Force-Push) | niedrig | niedrig | `--force-all` als Fallback, dokumentiert |
| Blossom-Server löscht Bild | mittel | mittel | Multi-Upload zu mehreren Servern sobald kind:10063 erweitert ist |
| `encodeURIComponent` vs. All-Inkl Apache: URL-Matching fällt auseinander | niedrig | mittel | Tests gegen reale URLs; Normalisierungs-Regel (lowercase Slugs, ASCII-Filenames bevorzugt) |
| Blossom-Server löscht Bild | mittel | mittel | Multi-Upload zu mehreren Servern sobald kind:10063 erweitert ist; `nak blossom mirror` als Ausgleich |
| Blossom-Server komplett weg, kein Mirror | niedrig | hoch | eigener Blossom-Server auf Optiplex (Phase 5) als dauerhafter Anker |
| Privater Schlüssel-Recovery | niedrig | **katastrophal** | Amber hat Backup-Mechanismus; `nsec` zusätzlich offline auf Hardware sichern |
---
@ -636,7 +590,7 @@ Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppe
**Jetzt (Bunker-Stufe Amber, Phase 1 Blossom):**
- Handy mit Amber als einziger Signer, online während Publish-Runs.
- Ein Blossom-Server in `kind:10063` (primal).
- Legacy-Bilder auf All-Inkl für die 18 Altposts.
- Alle Bilder (auch die der 18 Altposts) auf Blossom.
- Relay-Liste mit 4 Public-Relays.
**Bunker-Stufe Optiplex (sobald Proxmox-Container läuft):**
@ -659,7 +613,7 @@ Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppe
- `deno task check` ohne Fehler.
- 18 Altposts via einmaligem `deno task publish --force-all` publiziert.
- Jeder Post in mindestens 2 Public-Relays abrufbar, in Habla.news korrekt gerendert.
- Bilder der 18 Posts via `/YYYY/MM/DD/<dtag>.html/<bildname>` auf All-Inkl erreichbar.
- Alle Bilder auf Blossom erreichbar (Hash-URL liefert die Datei).
- Ein neuer Test-Post via CI auf `main`-Push publiziert in unter 90 Sekunden ab Push.
- `publish-log.json` enthält aussagekräftige Einträge pro Post.
- Pipeline läuft ohne nsec-Exposition in irgendeiner Umgebung.

View File

@ -0,0 +1,277 @@
# Konvention: Bild-Metadaten im Post-Frontmatter (Phase 1)
**Datum:** 2026-04-16
**Status:** Phase-1-Minimal — fokussiert auf sichere Attribution und `alt`-Vollständigkeit. Caption-Rendering, Reverse-Routine, License-Katalog und strikte Validierung sind explizit Phase 2.
**Scope:** YAML-Frontmatter-Schema für Bildmetadaten in Markdown-Posts. Wird von der Publish-Pipeline in `kind:30023`-Events (NIP-23) plus `imeta`-Tags (NIP-92) + `license`-Tag abgebildet.
## Ziele
1. **Sichere Attribution** — keine stille Fehlattribuierung. Fehlende Kenntnis wird explizit als `UNKNOWN` markiert, nie implizit geerbt.
2. **Menschlich lesbares, minimal-invasives YAML** — Defaults kommen aus Env, Frontmatter enthält nur das Abweichende.
3. **Blaupausen-Tauglichkeit** — funktioniert für beliebige Repos mit 1..n Autoren, Eigen- und Fremdbildern.
4. **Eine Datenstruktur pro Konzept** — Cover ist nur ein Bild mit Rolle. Kein paralleler Schema-Zweig.
---
## 1. Post-Ebene
```yaml
---
title: "Schokoschnecken"
slug: "jojos-schoko-zimt-schnecken"
date: 2023-02-26
# Lizenz des Post-TEXTES. Gilt NICHT automatisch für Bilder.
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
# Text-Autoren. Weglassen, wenn DEFAULT_AUTHORS aus Env gelten soll.
# Immer Array, auch bei einem Autor.
authors:
- name: "Jörg Lohrer"
url: "https://joerg-lohrer.de/" # optional
orcid: "..." # optional, frei erweiterbar
---
```
**Regeln:**
- `license` fehlt → Env-Default `DEFAULT_LICENSE` greift für den Text.
- `authors` fehlt → Env-Default `DEFAULT_AUTHORS` greift für den Text.
- **Diese Werte gelten ausschließlich für den Post-TEXT.** Für Bilder gibt es keine automatische Vererbung. Bilder haben eigene Lizenz- und Autor-Felder (siehe Abschnitt 2).
### 1.1 `date`
Erlaubtes Format: `YYYY-MM-DD` (wird als `00:00:00 UTC` interpretiert) oder ISO-8601 mit Uhrzeit (`YYYY-MM-DDTHH:MM:SSZ`). Zeitzone immer UTC, keine lokale TZ. Die Pipeline leitet daraus `published_at` (Unix-Sekunden) ab, stabil über Edits.
---
## 2. Bilder — einheitliche Liste
**Alle** Bilder eines Posts (Cover wie Body-Bilder) leben in einer einzigen `images`-Liste. Das Cover ist ein Bild mit `role: cover`.
```yaml
images:
# Cover-Bild
- file: cover.jpg
role: cover
alt: "Goldbraune Hefeschnecken auf Kuchenblech, frisch gebacken"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
# Body-Bild, eigenes Foto
- file: Hefeteig-mit-Fuellung.jpg
alt: "Hefeteig mit Kakao-Zimt-Zucker-Füllung, ausgerollt auf Backpapier"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
# Body-Bild, Herkunft unklar (Altpost, noch zu recherchieren)
- file: altes-bild.jpg
alt: "Screenshot der Startseite eines Lern-Portals"
license: UNKNOWN
authors: UNKNOWN
# Body-Bild, Fremdbild mit vollen Angaben
- file: fremdfoto.jpg
alt: "Osterküken mit Osterei"
authors:
- name: "Vera Kratochvil"
source_url: "https://www.publicdomainpictures.net/de/view-image.php?image=13188"
license: "https://creativecommons.org/publicdomain/zero/1.0/"
modifications: "beschnitten" # optional (das B in TULLU-BA)
```
### 2.1 Feld-Referenz
| Feld | Pflicht | Wert | Semantik |
|---|---|---|---|
| `file` | ja | String | Dateiname relativ zum Post-Ordner. Datei muss existieren. |
| `role` | nein | `cover` | Genau ein Bild pro Post darf `role: cover` haben. Dessen URL landet im Event-`image`-Tag. Kein `role` → Body-Bild. |
| `alt` | ja | String | Accessibility-Beschreibung. Leerstring `""` ist erlaubt (Dekorationsbild), fehlendes Feld ist ein Validierungsfehler. |
| `caption` | nein | String | Optionaler menschlicher Kontext (z. B. „Teig vor dem Einrollen"). Wird in Phase 1 nur in `imeta` als `caption`-Feld eingetragen. |
| `license` | ja | URL \| `UNKNOWN` | Volle URL im schema.org-Stil **oder** `UNKNOWN` als expliziter Marker. Kein Inheritance. |
| `authors` | ja | Array \| `UNKNOWN` | Array von `{name, url?, orcid?, ...}` **oder** `UNKNOWN`. Kein Inheritance. |
| `source_url` | nein | URL | Originalquelle / Fundstelle des Bildes. |
| `modifications` | nein | String | Freitext-Beschreibung einer Bearbeitung („beschnitten", „Kontrast angehoben", …). |
### 2.2 `UNKNOWN`-Semantik
`UNKNOWN` ist ein **einzelner** sauberer Marker — kein leeres Feld, kein `null`, kein Weglassen. Nutzen:
- Pipeline schreibt das Feld **nicht** in den `imeta`-Tag.
- Pipeline **loggt eine Warnung** pro `UNKNOWN`-Vorkommen (mit Post-Slug + Dateiname) — dient als Recherche-Liste.
- In Phase 1 ist `STRICT_MODE` default `false`: Events werden trotzdem publiziert.
- In Phase 2 kann `STRICT_MODE=true` Events mit `UNKNOWN` blockieren.
### 2.3 Bilder im Body
Im Markdown-Body werden Bilder weiterhin schlicht referenziert:
```markdown
![](Hefeteig-mit-Fuellung.jpg)
```
oder (für Migration tolerant):
```markdown
![Hefeteig mit Füllung](Hefeteig-mit-Fuellung.jpg)
```
Der Alt-Text im Markdown ist **niedriger priorisiert** als `alt` aus `images[]`. Er dient nur als Fallback für Bilder, die nicht in `images[]` stehen.
**Reihenfolge:** `images[]` ist ein Metadaten-Lookup per `file`, **keine** Sequenz. Die YAML-Reihenfolge muss nicht der Body-Reihenfolge entsprechen. Die Pipeline sortiert für Log-Output alphabetisch nach `file`.
### 2.4 Body-Captions aus Altposts
Bestehende in-body-Captions (z. B. Lead-in-Sätze vor Bildern, italic-Attributionen nach Bildern) bleiben unberührt. Phase 1 injiziert **nichts** in den Body. Redundanz oder Entfernen ist eine Phase-2-Entscheidung.
---
## 3. Abbildung auf das Nostr-Event (kind:30023)
### 3.1 Pflicht- und Standard-Tags (NIP-23)
| Tag | Quelle |
|---|---|
| `["d", slug]` | Frontmatter `slug` |
| `["title", title]` | Frontmatter `title` |
| `["published_at", unix]` | Frontmatter `date` (stabil über Edits) |
| `["summary", ...]` | Frontmatter `description` |
| `["image", url]` | URL des Bildes mit `role: cover` nach Blossom-Upload |
| `["t", tag]` | je ein Eintrag aus Frontmatter `tags[]` |
### 3.2 Lizenz und Autoren (Post-Text-Ebene)
| Tag | Quelle |
|---|---|
| `["license", url]` | Post-`license` (einmal pro Event, nur für Text-Lizenz) |
| `["p", pubkey, relay-hint, role]` | optional, wenn Text-Autoren einen Nostr-Pubkey haben — Phase 2 |
Für Phase 1 wird **nur** der `license`-Tag des Post-Textes geschrieben.
### 3.3 `imeta`-Felder pro Bild (NIP-92 plus Extensions)
Pro hochgeladenem Bild ein Tag:
```
["imeta",
"url <blossom-url>",
"m <mime>",
"x <sha256>",
"alt <alt>", // nur wenn nicht leer
"caption <caption>", // nur wenn vorhanden
"license <url>", // nur wenn konkrete URL (nicht UNKNOWN)
"author <name>", // eins pro Autor, nur wenn konkret (nicht UNKNOWN)
"source_url <url>", // nur wenn vorhanden
"modifications <text>" // nur wenn vorhanden
]
```
**Regeln:**
- `url`, `m`, `x` sind Pflicht und kommen aus dem Blossom-Upload.
- `UNKNOWN`-Werte werden **weggelassen** (kein Feld im Tag).
- Leerer `alt` wird weggelassen.
- Mehrere Autoren → mehrere `author`-Einträge im selben Tag.
### 3.4 NIP-89 `client`-Tag
Wenn Env `CLIENT_TAG` gesetzt ist: `["client", "<name>"]`. Default leer → kein Tag. Opt-in für Blaupausen, die Provenance markieren wollen.
### 3.5 Referenzen (`a`, `e`) — Phase 2
Aus optionalem Frontmatter `references:` (Array von `nostr:naddr…` / `nostr:nevent…`) werden `a`/`e`-Tags dekodiert. In Phase 1 nicht implementiert.
### 3.6 Body-Caption-Injektion — Phase 2
Automatische Injektion menschenlesbarer Attribution unter jedes Bild im Event-`content`. In Phase 1 nicht implementiert — reine `imeta`-Tags reichen für NIP-23-konforme Clients. Ob/wie in Phase 2 gebaut, wird anhand konkreter Client-Lücken entschieden.
### 3.7 Reverse-Routine — Phase 2
Rekonstruktion von strukturierten `images[]`-Einträgen aus nacktem Markdown mit injizierten Captions. In Phase 1 nicht benötigt.
---
## 4. Env-Defaults (Blaupause)
| Env | Default | Zweck |
|---|---|---|
| `DEFAULT_LICENSE` | `https://creativecommons.org/publicdomain/zero/1.0/deed.de` | Post-Text-Lizenz, wenn Frontmatter `license` fehlt |
| `DEFAULT_AUTHORS` | `[]` | Post-Text-Autoren als JSON-Array `[{"name":"…"}]`, wenn Frontmatter `authors` fehlt |
| `CLIENT_TAG` | *(leer)* | NIP-89 client-Provenance, opt-in |
| `STRICT_MODE` | `false` | Phase 1: Warnungen statt Fehler bei `UNKNOWN`. Phase 2: kann auf `true` gesetzt werden |
**Wichtig:** Env-Defaults greifen nur für die **Post-Text-Lizenz und Post-Text-Autoren**. Sie greifen **nicht** für Bilder. Bilder brauchen explizite `license` und `authors` pro Eintrag (oder `UNKNOWN`).
---
## 5. Validierung (Phase 1 — minimal)
Der `validate-post`-Subcommand prüft:
1. Jedes Bild in `images[]` hat ein `alt`-Feld (Leerstring erlaubt, fehlendes Feld verboten).
2. Jeder `file`-Wert referenziert eine existierende Datei im Post-Ordner.
3. Jedes im Body mit `![](filename)` referenzierte Bild existiert als Datei.
4. Maximal ein Bild hat `role: cover`.
**Explizit NICHT geprüft in Phase 1:**
- `license` vorhanden oder well-formed (Env-Default für Text greift; Bilder dürfen `UNKNOWN` sein)
- `authors` vorhanden oder non-empty (dito)
- URL-Wohlgeformtheit über `string.startsWith('http')` hinaus
- Orphan-Bilder (Bilder im Ordner, die nicht in `images[]` stehen und nicht im Body referenziert sind)
---
## 6. Migrations-Workflow (die 18 Altposts)
**Vor** der Pipeline-Implementierung wird einmalig ein Redaktions-Durchlauf gemacht, Claude-assistiert. Pro Post:
1. Bestehendes Frontmatter lesen.
2. Bilder im Post-Ordner listen. Hugo-Derivate (`*_hu_*.ext`) ignorieren.
3. Body-Kontext extrahieren (Text vor/nach jedem Bild + Dateiname).
4. Für jedes Bild schlägt Claude vor:
- `alt` (aus Kontext + Dateiname abgeleitet)
- `role: cover` für das Frontmatter-Cover-Bild
- `license` + `authors` = Eigenwerte, **wenn** der Kontext klar auf Eigenaufnahme hindeutet; sonst `UNKNOWN` mit Notiz
5. Jörg reviewt, korrigiert, nickt ab.
6. Pipeline-Autor schreibt Frontmatter-Patch.
7. Commit pro Post oder gebündelt nach Batch.
**Minimaler Fall pro Post:**
```yaml
---
# bisheriges Frontmatter bleibt
# ergänzt wird:
images:
- file: cover.jpg
role: cover
alt: "..."
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: bild1.jpg
alt: "..."
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
---
```
Fremdbilder bekommen `source_url`, Bilder mit unklarer Provenienz `UNKNOWN`.
---
## 7. Was in Phase 2 entschieden wird
- **Caption-Rendering-Format** (Kurzform-Katalog, Host-Extraktion, Locale-Normalisierung)
- **Body-Caption-Injektion** oder Verzicht
- **Reverse-Routine** aus Caption → YAML
- **`STRICT_MODE=true`** als Standard
- **Orphan-Bild-Detection** in der Validierung
- **`references:`-Feld** für `a`/`e`-Cross-References
- **`p`-Tags** für Text-Autoren mit Nostr-Pubkeys

View File

@ -0,0 +1,274 @@
# Structured Image Metadata for Markdown-Sourced Nostr Long-Form Content
**Status:** Working draft — a practice convention, not (yet) a NIP.
**Scope:** Authors who maintain Markdown long-form posts (`kind:30023`, NIP-23) in a git repository and publish them to Nostr via a build pipeline. The convention defines how image metadata (author, license, source, alt text, caption) lives in the repository, how it becomes `imeta` tags (NIP-92) in the event, and how to round-trip between the two.
**Goal:** Zero data loss between repository and event. Human-readable in raw Markdown. Machine-readable in the published event. Safe defaults against accidental misattribution.
---
## Why this exists
Markdown's native image syntax — `![alt](file.png)` — only carries two fields: the target and an alt text. Everything else a properly attributed image needs (author, license, license link, source, modifications — the "TULLU-BA" rule in German copyright practice) has nowhere to go.
Authors have three unsatisfying options today:
1. **Stuff everything into a visible caption line** under each image. Good for human readers, bad for machine parsing, risky because easily forgotten or inconsistent.
2. **Inline HTML `<figure>` blocks** with `<figcaption>`. Breaks Markdown lint tooling, hard to re-edit.
3. **Lose the metadata entirely.** Silent misattribution risk when the post is re-published without provenance.
NIP-92's `imeta` tag fixes the event-side machine-readability problem (url, mime, sha256, alt, etc. per image). But it doesn't answer where the data lives *before* the event exists.
This convention proposes: **structured YAML frontmatter as source of truth, free-form Markdown body for prose, deterministic bidirectional mapping between them.**
---
## The convention in one example
```yaml
---
title: "Schoko-Zimt-Schnecken"
slug: "schoko-schnecken"
date: 2023-02-26
# Text license (the post body). Image licenses are set per image.
license: "https://creativecommons.org/publicdomain/zero/1.0/"
# Post text authors (array, even for single author).
authors:
- name: "Jane Doe"
url: "https://jane.example/"
images:
- file: cover.jpg
role: cover
alt: "Golden baked yeast buns in a round pan, fresh from the oven"
license: "https://creativecommons.org/publicdomain/zero/1.0/"
authors:
- name: "Jane Doe"
- file: dough-filling.jpg
alt: "Rolled-out yeast dough, spread with cocoa-cinnamon-sugar filling"
license: "https://creativecommons.org/publicdomain/zero/1.0/"
authors:
- name: "Jane Doe"
# Foreign image with full TULLU-BA attribution:
- file: flickr-buns.jpg
alt: "Basket of freshly baked cinnamon rolls"
caption: "On a market stall in Lyon"
authors:
- name: "Max Mustermann"
source_url: "https://www.flickr.com/photos/mustermann/12345/"
license: "https://creativecommons.org/licenses/by-sa/4.0/"
modifications: "cropped"
---
Roll out the dough and spread the filling evenly:
![](dough-filling.jpg)
Slice into 16 pieces and arrange in the pan...
```
The Markdown body stays clean. The YAML carries the truth.
---
## Field reference
### Post-level (applies to the post text, not images)
| Field | Required | Type | Semantics |
|---|---|---|---|
| `license` | yes | URL | License of the post **text**. Does **not** cascade to images. |
| `authors` | yes | Array of `{name, url?, orcid?, ...}` | Authors of the post text. Array even with one author. |
Pipeline implementations may provide env-level defaults (`DEFAULT_LICENSE`, `DEFAULT_AUTHORS`) so single-author blogs don't repeat the same block on every post.
### Per-image (under the `images:` list)
| Field | Required | Type | Semantics |
|---|---|---|---|
| `file` | yes | String | Filename relative to the post directory. Must exist on disk. |
| `role` | no | `cover` | At most one image per post may carry `role: cover`. Its URL becomes the event's `image` tag. |
| `alt` | yes | String | Accessibility description. Empty string is allowed (decorative image); missing field is a validation error. |
| `caption` | no | String | Optional human context beyond the alt text. |
| `license` | yes | URL or `UNKNOWN` | Full schema.org-style license URL, or the literal `UNKNOWN`. No cascading from post-level. |
| `authors` | yes | Array or `UNKNOWN` | Author list, or the literal `UNKNOWN`. No cascading from post-level. |
| `source_url` | no | URL | Where the image was originally sourced (Flickr, Sketchfab, self-reference, etc.). |
| `modifications` | no | String | Free-text description of any derivative work ("cropped", "color-adjusted", "AI-generated with prompt: ..."). The "BA" in TULLU-BA. |
### Why no cascading
Cascading license/author from post to images was rejected after early prototypes: it makes **silent misattribution** the easy default. If a post is tagged `license: CC0` and a contributor adds a foreign image without noticing, the image inherits CC0 implicitly and ships to Nostr with a false attribution.
Explicit per-image fields cost a few extra lines of YAML and prevent an entire class of attribution bugs.
### `UNKNOWN` as an explicit value
For legacy content where provenance has been lost:
```yaml
- file: old-screenshot.png
alt: "Screenshot of a now-defunct learning portal's homepage"
license: UNKNOWN
authors: UNKNOWN
```
Pipeline behavior:
- Fields set to `UNKNOWN` are **not** written into the `imeta` tag (they are simply absent, not wrongly stated).
- A warning is logged per `UNKNOWN` field with post slug + filename — this becomes a research backlog.
- A strict mode can block publication when `UNKNOWN` values are present (opt-in).
---
## Mapping to the Nostr event
A post with this frontmatter produces a `kind:30023` event containing:
### Standard NIP-23 tags
- `["d", "<slug>"]`
- `["title", "<title>"]`
- `["published_at", "<unix-seconds>"]`
- `["summary", "<description>"]` if present
- `["image", "<cover-blossom-url>"]` — from the image marked `role: cover`
- `["t", "<tag>"]` per entry in `tags:`
### Text license
- `["license", "<url>"]` — once per event, from post-level `license`
### Per-image `imeta` (NIP-92 + extensions)
Each uploaded image yields one `imeta` tag:
```
["imeta",
"url <blossom-url>",
"m <mime-type>",
"x <sha256>",
"alt <alt>", if non-empty
"caption <caption>", if present
"license <url>", if set (not UNKNOWN)
"author <name>", one entry per author, if set (not UNKNOWN)
"source_url <url>", if present
"modifications <text>" if present
]
```
NIP-92 explicitly allows implementers to add fields beyond its core set; clients ignore unknown fields. `license`, `author`, `source_url`, `modifications` are extensions this convention uses to carry TULLU-BA data inline with the image reference.
### Markdown body transformation
The Markdown body is traversed: each `![alt](filename.png)` is replaced with `![alt](<blossom-url>)` after the image has been uploaded. Size hints (`![alt](file.png =300x200)`) are stripped. Absolute URLs in the source are preserved.
---
## Round-trip: YAML ↔ Markdown
The convention is designed so authors can work in **either direction**:
### Forward: YAML → published event
1. Pipeline parses frontmatter.
2. For each `images[]` entry, uploads `file` to Blossom, receives `{url, sha256}`.
3. Builds mapping `filename → blossom-url`.
4. Rewrites Markdown body image references.
5. Assembles `imeta` tags from the structured fields + upload results.
6. Signs and publishes.
### Reverse: "flat" Markdown → YAML
Some authors write Markdown with visible attribution lines underneath images, like:
```markdown
![Yeast dough with filling](dough-filling.jpg)
*Photo: Jane Doe, [CC0](https://creativecommons.org/publicdomain/zero/1.0/)*
![Cinnamon rolls at the market](flickr-buns.jpg)
*Photo: Max Mustermann via [Flickr](https://www.flickr.com/photos/mustermann/12345/), [CC BY-SA](https://creativecommons.org/licenses/by-sa/4.0/), cropped*
```
A round-trip parser can reconstruct the `images[]` YAML from this pattern because it follows a predictable shape:
```
![<alt>](<file>)
*Photo: <name>{, <name2>}{ via [<source-label>](<source-url>)}, [<license-label>](<license-url>){, <modifications>}.*
```
**Recognizable tokens** for the reverse parser:
- Image reference: standard Markdown `![alt](file)` on its own line.
- Attribution line: starts on the next line, wrapped in `*...*`, begins with a role word (`Photo`, `Foto`, `Image`, `Abb.`, etc.), ends with a period.
- **Authors**: comma-separated names between the role word and either `via` or the license bracket.
- **Source**: `via [<label>](<url>)`. The label is derived from the hostname if generated forward; on reverse, it's discarded and only the URL is kept.
- **License**: `[<short>](<url>)`. On reverse, only the URL is kept.
- **Modifications**: a trailing fragment after the license link, before the final period.
### Canonical caption format
Forward generation (YAML → caption string) uses a deterministic template:
```
{caption + ". "}Photo: {authors joined by " / "}{ via [<source-host>](<source_url>)}, [<license-short>]({license_url}){, <modifications>}.
```
With a license short-form catalog:
| License URL prefix | Short form |
|---|---|
| `https://creativecommons.org/publicdomain/zero/1.0/` | `CC0` |
| `https://creativecommons.org/licenses/by/4.0/` | `CC BY 4.0` |
| `https://creativecommons.org/licenses/by-sa/4.0/` | `CC BY-SA 4.0` |
| `https://creativecommons.org/licenses/by-nd/4.0/` | `CC BY-ND 4.0` |
| `https://creativecommons.org/licenses/by-nc/4.0/` | `CC BY-NC 4.0` |
| `https://creativecommons.org/licenses/by-nc-sa/4.0/` | `CC BY-NC-SA 4.0` |
| `https://creativecommons.org/licenses/by-nc-nd/4.0/` | `CC BY-NC-ND 4.0` |
| *anything else* | hostname of the URL |
Locale suffixes (`/deed.de`, `/deed.en`) are collapsed to the base URL for short-form lookup.
---
## Why this is forward-safe
Three properties make the convention robust over time:
1. **Events are replaceable.** A post re-published with improved metadata (better alt text, filled-in `UNKNOWN` fields) simply overrides the previous event via NIP-23's `d`-tag identity.
2. **`imeta` extensions degrade gracefully.** Clients that don't read `license`/`author`/`source_url` in `imeta` ignore them; they still get the standard `url`/`m`/`x`/`alt` fields.
3. **Reverse parsing is optional.** A pipeline can publish without ever supporting the reverse direction; the YAML is always the source of truth.
---
## What this convention does **not** do
- **Does not inject captions into the event body.** Early drafts did; it turned into a fragile regex workout across Markdown variants (link-wrapped images, list-embedded images, block quotes). Recommended approach: let clients render attribution from `imeta` fields. Inject body captions only if a concrete client gap makes it necessary.
- **Does not define new Nostr kinds.** It uses `kind:30023` (NIP-23), `kind:10063` (Blossom user server list, BUD-03), and `kind:10002` (NIP-65 relay list) as-is.
- **Does not mandate Blossom.** The convention maps cleanly to any content-addressed image host. Blossom is just the most interoperable option in the Nostr ecosystem today.
---
## Open questions for the community
1. **License in `imeta` — convention or its own tag?** Should per-image license info live in `imeta` as a non-standard field, or should there be a companion `license` tag per image with an `x <sha256>` back-reference? The `imeta` approach keeps everything per-image in one tag. A separate tag decouples concerns but duplicates the binding.
2. **Multiple licenses per image.** CC dual-licensing exists (e.g. "CC BY-SA or GFDL"). Should the spec allow `license` as an array, or repeat the `license` field multiple times in `imeta`?
3. **Canonical short-form catalog.** The table above is practical but not authoritative. Should a registry of license-URL-to-short-form mappings live somewhere reference-able?
4. **Attribution in languages other than English.** The reverse-parser pattern uses role words like `Photo`, `Foto`, `Image`. A language-agnostic marker (e.g. a leading emoji or a structured sigil like `⸻ credit ⸻`) would sidestep i18n, at the cost of readability.
5. **Machine-readable attribution in client rendering.** Long-form clients (Habla, Flycat, etc.) vary in how (and whether) they surface `imeta.license` / `imeta.author`. Adoption of this convention is only valuable if clients pick it up — a reference renderer implementation would lower the bar.
---
## References
- [NIP-23 — Long-form Content](https://github.com/nostr-protocol/nips/blob/master/23.md)
- [NIP-92 — Media Attachments (`imeta`)](https://github.com/nostr-protocol/nips/blob/master/92.md)
- [NIP-65 — Relay List Metadata (`kind:10002`)](https://github.com/nostr-protocol/nips/blob/master/65.md)
- [Blossom BUD-01 — Server Requirements](https://github.com/hzrd149/blossom/blob/master/buds/01.md)
- [Blossom BUD-03 — User Server List (`kind:10063`)](https://github.com/hzrd149/blossom/blob/master/buds/03.md)
- [TULLU / TULLU-BA attribution rule (German, Wikimedia practice)](https://commons.wikimedia.org/wiki/Commons:Lizenzhinweisgenerator)
- [schema.org/CreativeWork — `license` field convention](https://schema.org/license)

View File

@ -0,0 +1,283 @@
# Strukturierte Bild-Metadaten für Markdown-basierte Nostr-Langform-Beiträge
**Status:** Arbeitsentwurf — eine Praxis-Konvention, (noch) kein NIP.
**Scope:** Eine Inline-Markdown-Konvention zur Bildattribution (Urheber, Lizenz, Quelle, Bearbeitung), die in jedem Markdown-Editor direkt nutzbar ist und sich verlustfrei auf NIP-92-`imeta`-Tags in `kind:30023`-Events abbilden lässt.
**Ziel:** Ein einheitliches, menschlich lesbares und maschinell parsbares Attributions-Format für Bilder in Nostr-Langform-Beiträgen. TULLU-BA-konform. Zero-Tool: funktioniert ohne Build-Pipeline. Zero-Loss: bidirektional konvertierbar zu `imeta`-Tags, sobald Publishing dazukommt.
---
## Warum es das braucht
Markdowns native Bild-Syntax — `![alt](datei.png)` — trägt nur zwei Felder: das Ziel und einen Alt-Text. Alles andere, was ein korrekt attribuiertes Bild braucht (Urheber, Lizenz, Link zur Lizenz, Quelle, Bearbeitungen — die TULLU-BA-Regel aus der deutschen Urheberrechtspraxis), hat keinen Platz.
Autor:innen haben heute drei unbefriedigende Optionen:
1. **Attribution als freier Fließtext** unter jedem Bild. Gut für Menschen, nicht parsbar.
2. **Inline-HTML-`<figure>`-Blöcke** mit `<figcaption>`. Bricht Markdown-Lint-Tools, schwer editierbar.
3. **Metadaten weglassen.** Risiko stiller Fehlattribution.
NIP-92s `imeta`-Tag löst die Event-seitige Maschinenlesbarkeit (url, mime, sha256, alt usw. pro Bild). Diese Konvention liefert das fehlende Gegenstück: **wie dieselben Informationen bereits im Markdown stehen können — einheitlich, lesbar, parsbar**.
---
## Konvention zur Bildattribution
### Maximale Beispiel-Darstellung
![Rhabarberpflanze mit großen grünen Blättern und roten Stielen in einem Gartenbeet mit Mulch](https://inaturalist-open-data.s3.amazonaws.com/photos/71812633/medium.jpg)
[garden rhubarb, Speise-Rhabarber](https://www.inaturalist.org/photos/71812633), [John Sankey](https://www.inaturalist.org/users/2831535), [CC0](https://creativecommons.org/publicdomain/zero/1.0/), beschnitten
### Maximale Beispiel-Konstruktion
```markdown
![alt](imageUrl)
[title](sourceUrl), [author](authorUrl), [licence](licenceUrl), modification
```
Die Caption-Zeile steht **auf der Zeile direkt nach dem Bild** (Zeilenumbruch, kein Leerzeichen dazwischen).
---
## Regeln
1. **Reihenfolge der Felder:** `alt`, `imageUrl`, `title`, `sourceUrl`, `author`, `authorUrl`, `licence`, `licenceUrl`, `modification`. Die Reihenfolge ist **normativ**, damit Parser sich darauf verlassen können.
2. **Trenner:** Komma + Leerzeichen (`, `) zwischen den Caption-Feldern. Einheitlich, kein Mix aus „von", „via", Pipe usw.
3. **Verlinkungen:**
- `title``sourceUrl`
- `author``authorUrl`
- `licence``licenceUrl`
4. **URL-Disziplin:** Alle URL-Felder sind absolut (`https://…`), niemals relativ.
5. **CC0 / Public Domain:** `sourceUrl` darf entfallen. Urheber:in und Lizenz bleiben aus Transparenzgründen empfohlen.
6. **Bearbeitungen:** Bei CC-BY-Lizenzen ist die Änderung anzugeben, sobald das Werk verändert wurde (Zuschnitt, Farbe, Skalierung, Kombination usw.). Bei CC0 optional.
7. **Barrierefreiheit:** `alt` ist formal optional, aber für WCAG/BITV-Konformität faktisch Pflicht. Leere eckige Klammern `![]` nur bei rein dekorativen Bildern.
---
## (Pflicht-)Felder
| Feld | Status | Bedeutung / Form |
|---|---|---|
| `licence` | **Pflicht** | Lizenz-Kurzform (`CC0`, `CC BY`, `CC BY-SA`, `©`, …) |
| `licenceUrl` | **Pflicht** | Kanonische Lizenz-URL, z. B. `https://creativecommons.org/publicdomain/zero/1.0/` |
| `imageUrl` | **Pflicht** | Absolute URL zur Bilddatei (sonst nicht renderbar) |
| `sourceUrl` | **Pflicht** außer bei CC0 | URL zur Quellseite (Link in `title`) |
| `author` | **Pflicht** außer bei CC0 | Name der Urheber:in |
| `authorUrl` | optional | Profil-/Homepage-URL der Urheber:in |
| `modification` | optional (Pflicht bei Bearbeitung von CC-BY-Werken) | Freitext zur Bearbeitung |
| `title` | optional | Titel des Werks |
| `alt` | optional (faktisch Pflicht für Accessibility) | Screen-Reader-Beschreibung |
---
## Minimale Beispiel-Darstellung
![](https://inaturalist-open-data.s3.amazonaws.com/photos/71812633/medium.jpg)
[CC0](https://creativecommons.org/publicdomain/zero/1.0/)
### Minimale Beispiel-Konstruktion
```markdown
![](imageUrl)
[licence](licenceUrl)
```
Die harte Mindestanforderung: **Bild + Lizenz-Link**. Alles andere darf weg, wenn es die Lizenz erlaubt (z. B. CC0).
---
## Zwischenformen
Zwischen Minimum und Maximum sind alle Teilmengen erlaubt, solange die Reihenfolge eingehalten wird und die Pflichtfelder der jeweiligen Lizenz erfüllt sind.
**CC0-Eigenbild mit Urheberangabe (empfohlen für Transparenz):**
```markdown
![Hase auf Wiese](cover.jpg)
Comenius-Institut, [CC0](https://creativecommons.org/publicdomain/zero/1.0/)
```
**CC-BY-Fremdbild ohne Titel:**
```markdown
![Schlüssel mit Schild "Ermutigung"](ermutigung.jpg)
[Jörg Lohrer](https://www.flickr.com/photos/empeiria/8553607289/), [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
```
---
## Parsing-Regeln (für Tooling)
Die Konvention ist für **Menschen** geschrieben. Parser haben die Aufgabe, sich daran möglichst anzupassen — **nicht umgekehrt**. Sonderzeichen, Rollen-Wörter oder sprach-abhängige Marker werden bewusst nicht vorgeschrieben, weil sie den Schreibfluss behindern würden.
Ein Parser erkennt eine Attributions-Caption anhand dieser Merkmale:
- **Position:** direkt nach einer Markdown-Bild-Zeile (`![alt](imageUrl)`), auf der nächsten Zeile ohne Leerzeile dazwischen.
- **Struktur:** eine oder mehrere `[label](url)`-Markdown-Links, getrennt durch `, `, optional abschließender Freitext-Teil für `modification`.
- **Feld-Zuordnung** nach Position in der Reihenfolge gemäß Regel 1:
- Erster Link vor einem eventuellen Personennamen-Link = `title` + `sourceUrl`
- Zweiter Link (Personenname) = `author` + `authorUrl`
- Dritter Link (CC-Kürzel) = `licence` + `licenceUrl`
- Alles danach (ohne Klammer-Syntax) = `modification`
**Eindeutige Fälle:**
- **Drei Links**`title`/`sourceUrl`, `author`/`authorUrl`, `licence`/`licenceUrl` in dieser Reihenfolge. Der letzte Link muss auf ein Lizenz-URL-Pattern matchen.
- **Zwei Links**, zweiter matcht Lizenz-Pattern → `author`/`authorUrl` + `licence`/`licenceUrl`. Ein Titel ohne Autor:in wird konventionell nicht vergeben — das erste `[Text](url)` ist in zwei-Link-Fällen immer `author`.
- **Ein Link + unverlinkter Text + Lizenz-Link** → unverlinkter Text ist `author`, Link vor der Lizenz wäre `title+sourceUrl`.
- **Nur ein Link**, matcht Lizenz-Pattern → `licence`/`licenceUrl`. Minimal-Form.
- **Unverlinkter String vor der Lizenz**`author` (ohne URL).
- **Freitext nach der Lizenz**`modification`.
**Mehrdeutige Fälle** (z. B. `[Etwas](url), [CC0](url)` — Autor oder Titel?):
- **Parser-Empfehlung:** LLM-gestützter Parser nimmt Kontext dazu (Bild-Alt-Text, Body-Kontext, Plattform-Muster der URL) und ordnet zu.
- **Reiner Regex-Parser:** markiert die Caption als **ambigue** und eskaliert zur redaktionellen Prüfung (statt zu raten).
- **Schreibende:** können Mehrdeutigkeit jederzeit selbst auflösen, indem sie beide Felder setzen (`[Titel](url), [Autor](url), [Lizenz](url)`). Ein Titel ohne Autor:in ist die Ausnahme; wer Eindeutigkeit braucht, ergänzt die Urheber:in.
Der Parser bricht nie stillschweigend. Eine Caption ist entweder eindeutig geparst, eindeutig Minimal-Form, oder **wird als prüfbedürftig markiert** — nie still falsch interpretiert.
---
## Abbildung auf das Nostr-Event (`imeta`, NIP-92)
Jedes Bild im Beitrag wird als eigener `imeta`-Tag im `kind:30023`-Event codiert:
```
["imeta",
"url <imageUrl>",
"m <mime>",
"x <sha256>",
"alt <alt>", wenn nicht leer
"title <title>", wenn vorhanden
"source_url <sourceUrl>", wenn vorhanden
"author <author>", wenn vorhanden; ein Eintrag pro Autor:in
"author_url <authorUrl>", wenn vorhanden
"license <licenceUrl>", Pflicht
"modification <modification>" wenn vorhanden
]
```
**Normativ:**
- `url`, `m`, `x`, `license` sind **Pflicht** im `imeta`.
- `license` ist immer die volle URL, nicht die Kurzform (maschinenlesbar, Clients können daraus die Kurzform zur Anzeige ableiten).
- `m` (mime) und `x` (sha256) kommen nicht aus der Caption, sondern werden beim Upload zum Blob-Host (z. B. Blossom) ermittelt.
**Erweiterung über NIP-92 hinaus:** Die Felder `title`, `source_url`, `author`, `author_url`, `modification` sind keine NIP-92-Kernfelder. NIP-92 erlaubt Implementierenden ausdrücklich, zusätzliche Felder einzuführen; Clients ignorieren unbekannte Felder. Diese Konvention nutzt diese Erweiterungsmöglichkeit, um TULLU-BA-Daten direkt beim Bild mitzuführen.
---
## Bidirektionale Abbildung (Markdown ↔ `imeta`)
### Hinweg: Markdown → `imeta`
1. Parser findet `![alt](imageUrl)` im Body.
2. Nächste Zeile wird als Caption interpretiert, Felder nach Reihenfolge-Regel extrahiert.
3. Bild wird hochgeladen (z. B. Blossom), `url`/`mime`/`sha256` werden aus der Upload-Antwort ergänzt.
4. `imeta`-Tag wird aus Caption-Feldern + Upload-Daten gebaut.
5. Markdown-Body wird angepasst: ursprüngliche `imageUrl` → Upload-URL. Die Caption-Zeile bleibt erhalten (oder wird entfernt, wenn der Client sie aus `imeta` rendert — Entscheidung des Publishing-Tools).
### Rückweg: `imeta` → Markdown
1. Client liest Event, extrahiert pro `imeta`-Tag die Felder.
2. Rendert `![alt](url)` mit `alt` aus dem Tag.
3. Rendert darunter eine Caption-Zeile mit den vorhandenen Feldern in der normativen Reihenfolge aus Regel 1.
4. `license` (URL) wird über einen Kurzform-Katalog (siehe Anhang) in eine lesbare Kurzform übersetzt (`CC0`, `CC BY 4.0`, …).
Weil die Reihenfolge normativ ist und die Trennzeichen einheitlich, lässt sich beides verlustfrei ineinander übersetzen.
---
## Beispiel: End-to-End
### Markdown im Editor
```markdown
![Rhabarberpflanze mit großen grünen Blättern und roten Stielen in einem Gartenbeet mit Mulch](https://inaturalist-open-data.s3.amazonaws.com/photos/71812633/medium.jpg)
[garden rhubarb, Speise-Rhabarber](https://www.inaturalist.org/photos/71812633), [John Sankey](https://www.inaturalist.org/users/2831535), [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), beschnitten
```
### Geparst
| Feld | Wert |
|---|---|
| `alt` | Rhabarberpflanze mit großen grünen Blättern … |
| `imageUrl` | https://inaturalist-open-data.s3.amazonaws.com/photos/71812633/medium.jpg |
| `title` | garden rhubarb, Speise-Rhabarber |
| `sourceUrl` | https://www.inaturalist.org/photos/71812633 |
| `author` | John Sankey |
| `authorUrl` | https://www.inaturalist.org/users/2831535 |
| `licence` | CC BY-SA 4.0 |
| `licenceUrl` | https://creativecommons.org/licenses/by-sa/4.0/ |
| `modification` | beschnitten |
### Als `imeta`-Tag im `kind:30023`-Event (nach Blossom-Upload)
```
["imeta",
"url https://blossom.example/abc123…def.jpg",
"m image/jpeg",
"x abc123…def",
"alt Rhabarberpflanze mit großen grünen Blättern und roten Stielen in einem Gartenbeet mit Mulch",
"title garden rhubarb, Speise-Rhabarber",
"source_url https://www.inaturalist.org/photos/71812633",
"author John Sankey",
"author_url https://www.inaturalist.org/users/2831535",
"license https://creativecommons.org/licenses/by-sa/4.0/",
"modification beschnitten"
]
```
### Beim Rendern in einem Nostr-Client
Der Client, der dieses `imeta` versteht, rekonstruiert die Caption nach derselben Konvention:
```markdown
![Rhabarberpflanze …](https://blossom.example/abc123…def.jpg)
[garden rhubarb, Speise-Rhabarber](https://www.inaturalist.org/photos/71812633), [John Sankey](https://www.inaturalist.org/users/2831535), [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), beschnitten
```
Ein Client, der die erweiterten `imeta`-Felder nicht kennt, zeigt immerhin `![alt](url)` korrekt an und ignoriert den Rest — Graceful Degradation.
---
## Anhang: Lizenz-URL → Kurzform-Katalog
| Lizenz-URL-Präfix | Kurzform |
|---|---|
| `https://creativecommons.org/publicdomain/zero/1.0/` | `CC0` |
| `https://creativecommons.org/publicdomain/mark/1.0/` | `Public Domain` |
| `https://creativecommons.org/licenses/by/4.0/` | `CC BY 4.0` |
| `https://creativecommons.org/licenses/by-sa/4.0/` | `CC BY-SA 4.0` |
| `https://creativecommons.org/licenses/by-nd/4.0/` | `CC BY-ND 4.0` |
| `https://creativecommons.org/licenses/by-nc/4.0/` | `CC BY-NC 4.0` |
| `https://creativecommons.org/licenses/by-nc-sa/4.0/` | `CC BY-NC-SA 4.0` |
| `https://creativecommons.org/licenses/by-nc-nd/4.0/` | `CC BY-NC-ND 4.0` |
| *alles andere* | Host der URL als Kurzform-Fallback |
Locale-Suffixe (`/deed.de`, `/deed.en`) werden bei der Kurzform-Auflösung auf die Basis-URL reduziert. Für Versionen (`3.0` statt `4.0`) wird die Version mit angezeigt.
---
## Offene Fragen an die Community
1. **Reihenfolge normativ oder locker?** Die normative Reihenfolge macht den Parser einfach. Eine lockere Variante (Felder an beliebiger Position, Erkennung per URL-Pattern) wäre toleranter, aber fragiler. Empfehlung: normativ. Meinungen?
2. **Mehrere Autor:innen pro Bild.** Ein Bild mit Ko-Autorenschaft: `[Jane Doe](…) / [John Doe](…)`? Oder Komma-getrennt `[Jane Doe](…), [John Doe](…)`? Letzteres kollidiert mit dem Feld-Trenner. Empfehlung: `/` als Autor:innen-Trenner innerhalb des `author`-Slots.
3. **Mehrere Lizenzen pro Bild.** CC-Dual-Licensing (z. B. „CC BY-SA **oder** GFDL") — `[CC BY-SA](url) / [GFDL](url)` analog zu Autor:innen?
4. **Kanonischer Kurzform-Katalog.** Die Tabelle ist praktikabel, aber nicht normativ. Eine Registry von Lizenz-URL-zu-Kurzform-Mappings, referenzierbar an einer Stelle, würde Interop erleichtern.
5. **Sprach-Rollen-Wörter.** Diese Konvention verzichtet auf einleitende Wörter wie „Foto:", „Photo:", „Bild:". Das macht sie sprach-agnostisch. Will jemand ein optionales Rollen-Wort erlauben (`*Foto: [title](url), …*`), damit Attributionen in langen Texten klarer identifizierbar sind?
6. **Repo-Workflow-Ergänzung.** Wer Markdown in einem Git-Repo mit Build-Pipeline pflegt, möchte manchmal Metadaten **strukturiert im YAML-Frontmatter** statt im Body. Ein paralleler YAML-Mapping (gleiche Felder, gleiche Semantik, Array unter `images:`) kann als Ergänzung leben, wobei die Inline-Markdown-Form die Basis bleibt und beides bidirektional konvertierbar ist.
---
## Referenzen
- [NIP-23 — Long-form Content](https://github.com/nostr-protocol/nips/blob/master/23.md)
- [NIP-92 — Media Attachments (`imeta`)](https://github.com/nostr-protocol/nips/blob/master/92.md)
- [Blossom BUD-01 — Server Requirements](https://github.com/hzrd149/blossom/blob/master/buds/01.md)
- [TULLU / TULLU-BA Attributions-Regel (Wikimedia Deutschland)](https://commons.wikimedia.org/wiki/Commons:Lizenzhinweisgenerator)
- [schema.org/CreativeWork — `license`-Feld](https://schema.org/license)
- [WCAG 2.1 — Accessible Alt Text](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html)

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>

23
publish/.env.example Normal file
View File

@ -0,0 +1,23 @@
# ==== PFLICHT ====
# NIP-46-Bunker-URL vom Signer (Amber, nak bunker, nsite.run, …)
BUNKER_URL=bunker://<hex>?relay=wss://...&secret=...
# Autor-Pubkey als 64 Zeichen lowercase hex (entspricht dem Bunker-Account)
AUTHOR_PUBKEY_HEX=
# Bootstrap-Relay zum Laden von kind:10002 und kind:10063
BOOTSTRAP_RELAY=wss://relay.damus.io
# ==== OPTIONAL ====
# Wurzel der Markdown-Posts, relativ zu diesem publish/-Ordner.
# Default: ../content/posts
CONTENT_ROOT=../content/posts
# Wird als ["client", "<wert>"]-Tag in jedes kind:30023-Event eingetragen.
# Hilft bei der Zuordnung der Event-Herkunft. Default leer (kein client-Tag).
CLIENT_TAG=
# Minimal geforderte Relay-ACKs pro Post (default: 2)
MIN_RELAY_ACKS=2

3
publish/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
logs/
deno.lock

31
publish/README.md Normal file
View File

@ -0,0 +1,31 @@
# publish — Nostr-Publish-Pipeline
Markdown-Posts aus einem Hugo-ähnlichen Content-Ordner zu `kind:30023`-Events,
Bilder zu Blossom, Signatur via NIP-46-Bunker.
Blaupause für Nostr-Repos: keinerlei Projekt-Konstanten im Code, alles über
Env-Variablen konfigurierbar.
## Setup
1. `cp .env.example .env` und Werte eintragen.
2. Oder: `.env.local` im Eltern-Ordner pflegen und `deno.jsonc` anpassen
(siehe `--env-file=../.env.local`-Tasks).
3. `deno task check` — verifiziert Bunker, Relay-Liste, Blossom-Server.
## Befehle
- `deno task publish` — Git-Diff-Modus: publisht nur geänderte Posts.
- `deno task publish --force-all` — alle Posts (Migration / Reimport).
- `deno task publish --post <slug>` — nur ein Post.
- `deno task publish --dry-run` — zeigt, was publiziert würde, ohne Uploads.
- `deno task validate-post content/posts/<ordner>/index.md` — Frontmatter-Check.
- `deno task test` — Tests.
## Struktur
- `src/core/` — Library (Frontmatter, Markdown, Events, Signer, Relays, Blossom).
- `src/subcommands/` — CLI-Befehle.
- `src/cli.ts` — Entrypoint, Subcommand-Dispatcher.
- `tests/` — Unit- und Integration-Tests.
- `.github/workflows/publish.yml` — CI-Workflow.

34
publish/deno.jsonc Normal file
View File

@ -0,0 +1,34 @@
{
"tasks": {
"publish": "deno run --env-file=../.env.local --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish",
"check": "deno run --env-file=../.env.local --allow-env --allow-read --allow-net src/cli.ts check",
"validate-post": "deno run --allow-read src/cli.ts validate-post",
"test": "deno test --allow-env --allow-read --allow-write --allow-net --allow-run",
"fmt": "deno fmt",
"lint": "deno lint"
},
"imports": {
"@std/yaml": "jsr:@std/yaml@^1.0.5",
"@std/cli": "jsr:@std/cli@^1.0.6",
"@std/fs": "jsr:@std/fs@^1.0.4",
"@std/path": "jsr:@std/path@^1.0.6",
"@std/testing": "jsr:@std/testing@^1.0.3",
"@std/assert": "jsr:@std/assert@^1.0.6",
"@std/encoding": "jsr:@std/encoding@^1.0.5",
"nostr-tools": "npm:nostr-tools@^2.10.4",
"applesauce-signers": "npm:applesauce-signers@^2.0.0",
"applesauce-relay": "npm:applesauce-relay@^2.0.0",
"rxjs": "npm:rxjs@^7.8.1"
},
"fmt": {
"lineWidth": 100,
"indentWidth": 2,
"semiColons": false,
"singleQuote": true
},
"lint": {
"rules": {
"tags": ["recommended"]
}
}
}

190
publish/src/cli.ts Normal file
View File

@ -0,0 +1,190 @@
import { parseArgs } from '@std/cli/parse-args'
import { join } from '@std/path'
import { loadConfig } from './core/config.ts'
import { createBunkerSigner } from './core/signer.ts'
import { loadOutbox } from './core/outbox.ts'
import { loadBlossomServers } from './core/blossom-list.ts'
import { parseFrontmatter } from './core/frontmatter.ts'
import { checkExisting, publishToRelays } from './core/relays.ts'
import { uploadBlob } from './core/blossom.ts'
import { collectImages } from './core/image-collector.ts'
import { allPostDirs, changedPostDirs } from './core/change-detection.ts'
import { createLogger, type RunMode } from './core/log.ts'
import { type PostDeps, processPost } from './subcommands/publish.ts'
import { printCheckResult, runCheck } from './subcommands/check.ts'
import { validatePostFile } from './subcommands/validate-post.ts'
function uuid(): string {
return crypto.randomUUID()
}
async function cmdCheck(): Promise<number> {
const config = loadConfig()
const result = await runCheck(config)
printCheckResult(result)
return result.ok ? 0 : 1
}
async function cmdValidatePost(path: string | undefined): Promise<number> {
if (!path) {
console.error('usage: validate-post <path-to-index.md>')
return 2
}
const result = await validatePostFile(path)
if (result.ok) {
console.log(`${path} ok (slug: ${result.slug})`)
return 0
}
console.error(`${path}: ${result.error}`)
return 1
}
async function findBySlug(dirs: string[], slug: string): Promise<string | undefined> {
for (const d of dirs) {
try {
const text = await Deno.readTextFile(join(d, 'index.md'))
const { fm } = parseFrontmatter(text)
if (fm.slug === slug) return d
} catch {
// skip
}
}
return undefined
}
async function resolvePostDirs(
mode: RunMode,
contentRoot: string,
single?: string,
): Promise<string[]> {
if (mode === 'post-single' && single) {
if (single.startsWith(contentRoot + '/')) return [single]
const all = await allPostDirs(contentRoot)
const match = all.find((d) => d.endsWith(`/${single}`)) ?? (await findBySlug(all, single))
if (!match) throw new Error(`post mit slug "${single}" nicht gefunden`)
return [match]
}
if (mode === 'force-all') return await allPostDirs(contentRoot)
const before = Deno.env.get('GITHUB_EVENT_BEFORE') ?? 'HEAD~1'
return await changedPostDirs({ from: before, to: 'HEAD', contentRoot })
}
async function cmdPublish(flags: {
forceAll: boolean
post?: string
dryRun: boolean
}): Promise<number> {
const config = loadConfig()
const mode: RunMode = flags.post ? 'post-single' : flags.forceAll ? 'force-all' : 'diff'
const runId = uuid()
const logger = createLogger({ mode, runId })
console.log('[1/3] signer…')
const signer = await createBunkerSigner(config.bunkerUrl, {
clientSecretHex: config.clientSecretHex,
})
console.log('[2/3] outbox…')
const outbox = await loadOutbox(config.bootstrapRelay, config.authorPubkeyHex)
console.log('[3/3] blossom-server-liste…')
const blossomServers = await loadBlossomServers(config.bootstrapRelay, config.authorPubkeyHex)
console.log('setup done')
if (outbox.write.length === 0) {
console.error('no write relays in kind:10002')
return 1
}
if (blossomServers.length === 0) {
console.error('no blossom servers in kind:10063')
return 1
}
const postDirs = await resolvePostDirs(mode, config.contentRoot, flags.post)
console.log(
`mode=${mode} posts=${postDirs.length} runId=${runId} contentRoot=${config.contentRoot}`,
)
if (flags.dryRun) {
for (const d of postDirs) console.log(` dry-run: ${d}`)
return 0
}
const deps: PostDeps = {
readPostFile: async (p) => parseFrontmatter(await Deno.readTextFile(p)),
collectImages: (dir) => collectImages(dir),
uploadBlossom: (a) =>
uploadBlob({
data: a.data,
fileName: a.fileName,
mimeType: a.mimeType,
servers: blossomServers,
signer,
}),
sign: (ev) => signer.signEvent(ev),
publish: (ev, relays) => publishToRelays(relays, ev),
checkExisting: (slug, relays) => checkExisting(slug, config.authorPubkeyHex, relays),
}
let anyFailed = false
for (const dir of postDirs) {
const result = await processPost({
postDir: dir,
writeRelays: outbox.write,
blossomServers,
pubkeyHex: config.authorPubkeyHex,
clientTag: config.clientTag,
minRelayAcks: config.minRelayAcks,
deps,
})
if (result.status === 'success') {
logger.postSuccess({
slug: result.slug,
action: result.action!,
eventId: result.eventId!,
relaysOk: result.relaysOk,
relaysFailed: result.relaysFailed,
blossomServersOk: result.blossomServersOk,
imagesUploaded: result.imagesUploaded,
durationMs: result.durationMs,
})
} else if (result.status === 'skipped-draft') {
logger.postSkippedDraft(result.slug)
} else {
anyFailed = true
logger.postFailed({
slug: result.slug,
error: result.error ?? 'unknown',
durationMs: result.durationMs,
})
}
}
const exitCode = anyFailed ? 1 : 0
const summary = logger.finalize(exitCode)
await Deno.mkdir('./logs', { recursive: true })
const logPath = `./logs/publish-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
await logger.writeJson(logPath, summary)
console.log(`log: ${logPath}`)
return exitCode
}
async function main(): Promise<number> {
const args = parseArgs(Deno.args, {
boolean: ['force-all', 'dry-run'],
string: ['post'],
})
const sub = args._[0]
if (sub === 'check') return cmdCheck()
if (sub === 'validate-post') return cmdValidatePost(args._[1] as string | undefined)
if (sub === 'publish') {
return cmdPublish({
forceAll: args['force-all'] === true,
post: args.post,
dryRun: args['dry-run'] === true,
})
}
console.error('usage: cli.ts <publish | check | validate-post> [flags]')
return 2
}
if (import.meta.main) {
Deno.exit(await main())
}

View File

@ -0,0 +1,22 @@
import { Relay } from 'applesauce-relay'
import { firstValueFrom, timeout } from 'rxjs'
import type { SignedEvent } from './relays.ts'
export function parseBlossomServers(ev: { tags: string[][] }): string[] {
return ev.tags
.filter((t) => t[0] === 'server' && t[1])
.map((t) => t[1].replace(/\/$/, ''))
}
export async function loadBlossomServers(
bootstrapRelay: string,
authorPubkeyHex: string,
): Promise<string[]> {
const relay = new Relay(bootstrapRelay)
const ev = await firstValueFrom(
relay
.request({ kinds: [10063], authors: [authorPubkeyHex], limit: 1 })
.pipe(timeout({ first: 10_000 })),
) as SignedEvent
return parseBlossomServers(ev)
}

View File

@ -0,0 +1,88 @@
import { encodeBase64 } from '@std/encoding/base64'
import type { Signer } from './signer.ts'
import type { UnsignedEvent } from './event.ts'
export interface BlossomClient {
fetch(url: string, init: RequestInit): Promise<Response>
}
export interface UploadArgs {
data: Uint8Array
fileName: string
mimeType: string
servers: string[]
signer: Signer
client?: BlossomClient
}
export interface UploadReport {
ok: string[]
failed: string[]
primaryUrl: string
sha256: string
}
async function sha256Hex(data: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', data as BufferSource)
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
async function buildAuth(signer: Signer, hash: string): Promise<string> {
const pubkey = await signer.getPublicKey()
const auth: UnsignedEvent = {
kind: 24242,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['t', 'upload'],
['x', hash],
['expiration', String(Math.floor(Date.now() / 1000) + 300)],
],
content: '',
}
const signed = await signer.signEvent(auth)
return 'Nostr ' + encodeBase64(new TextEncoder().encode(JSON.stringify(signed)))
}
async function uploadOne(
server: string,
data: Uint8Array,
mimeType: string,
auth: string,
client: BlossomClient,
): Promise<{ ok: boolean; url?: string }> {
try {
const resp = await client.fetch(server + '/upload', {
method: 'PUT',
headers: { authorization: auth, 'content-type': mimeType },
body: data as BodyInit,
})
if (!resp.ok) return { ok: false }
const json = await resp.json()
return { ok: true, url: json.url }
} catch {
return { ok: false }
}
}
const defaultClient: BlossomClient = { fetch: (u, i) => fetch(u, i) }
export async function uploadBlob(args: UploadArgs): Promise<UploadReport> {
const client = args.client ?? defaultClient
const hash = await sha256Hex(args.data)
const auth = await buildAuth(args.signer, hash)
const results = await Promise.all(
args.servers.map((s) =>
uploadOne(s, args.data, args.mimeType, auth, client).then((r) => ({ server: s, ...r }))
),
)
const ok = results.filter((r) => r.ok).map((r) => r.server)
const failed = results.filter((r) => !r.ok).map((r) => r.server)
if (ok.length === 0) {
throw new Error(`all blossom servers failed for ${args.fileName}`)
}
const first = results.find((r) => r.ok && r.url)!
return { ok, failed, primaryUrl: first.url!, sha256: hash }
}

View File

@ -0,0 +1,67 @@
export type GitRunner = (args: string[]) => Promise<string>
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function filterPostDirs(lines: string[], contentRoot: string): string[] {
const root = contentRoot.replace(/\/$/, '')
const prefix = root + '/'
const indexRe = new RegExp(`^${escapeRegex(prefix)}([^/]+)/index\\.md$`)
const assetRe = new RegExp(`^${escapeRegex(prefix)}([^/]+)/`)
const drafts = prefix + '_'
const dirs = new Set<string>()
for (const line of lines) {
const l = line.trim()
if (!l) continue
if (l.startsWith(drafts)) continue
const indexMatch = l.match(indexRe)
if (indexMatch) {
dirs.add(`${prefix}${indexMatch[1]}`)
continue
}
const assetMatch = l.match(assetRe)
if (assetMatch && !l.endsWith('.md')) {
dirs.add(`${prefix}${assetMatch[1]}`)
}
}
return [...dirs].sort()
}
const defaultRunner: GitRunner = async (args) => {
const proc = new Deno.Command('git', { args, stdout: 'piped', stderr: 'piped' })
const out = await proc.output()
if (out.code !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${new TextDecoder().decode(out.stderr)}`)
}
return new TextDecoder().decode(out.stdout)
}
export interface DiffArgs {
from: string
to: string
contentRoot: string
runner?: GitRunner
}
export async function changedPostDirs(args: DiffArgs): Promise<string[]> {
const runner = args.runner ?? defaultRunner
const stdout = await runner(['diff', '--name-only', `${args.from}..${args.to}`])
return filterPostDirs(stdout.split('\n'), args.contentRoot)
}
export async function allPostDirs(contentRoot: string): Promise<string[]> {
const result: string[] = []
for await (const entry of Deno.readDir(contentRoot)) {
if (entry.isDirectory && !entry.name.startsWith('_')) {
const indexPath = `${contentRoot}/${entry.name}/index.md`
try {
const stat = await Deno.stat(indexPath)
if (stat.isFile) result.push(`${contentRoot}/${entry.name}`)
} catch {
// skip folders without index.md
}
}
}
return result.sort()
}

View File

@ -0,0 +1,53 @@
export interface Config {
bunkerUrl: string
authorPubkeyHex: string
bootstrapRelay: string
contentRoot: string
clientTag: string
minRelayAcks: number
clientSecretHex?: string
}
type EnvReader = (key: string) => string | undefined
const REQUIRED = ['BUNKER_URL', 'AUTHOR_PUBKEY_HEX', 'BOOTSTRAP_RELAY'] as const
const DEFAULTS = {
CONTENT_ROOT: '../content/posts',
CLIENT_TAG: '',
MIN_RELAY_ACKS: '2',
}
export function loadConfig(read: EnvReader = (k) => Deno.env.get(k)): Config {
const missing: string[] = []
const values: Record<string, string> = {}
for (const key of REQUIRED) {
const v = read(key)
if (!v) missing.push(key)
else values[key] = v
}
if (missing.length) {
throw new Error(`Missing env: ${missing.join(', ')}`)
}
if (!/^[0-9a-f]{64}$/.test(values.AUTHOR_PUBKEY_HEX)) {
throw new Error('AUTHOR_PUBKEY_HEX must be 64 lowercase hex characters')
}
const minAcksRaw = read('MIN_RELAY_ACKS') ?? DEFAULTS.MIN_RELAY_ACKS
const minAcks = Number(minAcksRaw)
if (!Number.isInteger(minAcks) || minAcks < 1) {
throw new Error(`MIN_RELAY_ACKS must be a positive integer, got "${minAcksRaw}"`)
}
const clientSecretHex = read('CLIENT_SECRET_HEX')
if (clientSecretHex && !/^[0-9a-f]{64}$/.test(clientSecretHex)) {
throw new Error('CLIENT_SECRET_HEX must be 64 lowercase hex characters')
}
return {
bunkerUrl: values.BUNKER_URL,
authorPubkeyHex: values.AUTHOR_PUBKEY_HEX,
bootstrapRelay: values.BOOTSTRAP_RELAY,
contentRoot: read('CONTENT_ROOT') ?? DEFAULTS.CONTENT_ROOT,
clientTag: read('CLIENT_TAG') ?? DEFAULTS.CLIENT_TAG,
minRelayAcks: minAcks,
clientSecretHex,
}
}

43
publish/src/core/event.ts Normal file
View File

@ -0,0 +1,43 @@
import type { Frontmatter } from './frontmatter.ts'
export interface UnsignedEvent {
kind: number
pubkey: string
created_at: number
tags: string[][]
content: string
}
export interface BuildArgs {
fm: Frontmatter
rewrittenBody: string
coverUrl: string | undefined
pubkeyHex: string
clientTag: string
nowSeconds: number
additionalTags?: string[][]
}
export function buildKind30023(args: BuildArgs): UnsignedEvent {
const { fm, rewrittenBody, coverUrl, pubkeyHex, clientTag, nowSeconds, additionalTags } = args
const publishedAt = Math.floor(fm.date.getTime() / 1000)
const tags: string[][] = [
['d', fm.slug],
['title', fm.title],
['published_at', String(publishedAt)],
]
if (fm.description) tags.push(['summary', fm.description])
if (coverUrl) tags.push(['image', coverUrl])
if (Array.isArray(fm.tags)) {
for (const t of fm.tags) tags.push(['t', String(t)])
}
if (clientTag) tags.push(['client', clientTag])
if (additionalTags) tags.push(...additionalTags)
return {
kind: 30023,
pubkey: pubkeyHex,
created_at: nowSeconds,
tags,
content: rewrittenBody,
}
}

View File

@ -0,0 +1,47 @@
import { parse as parseYaml } from '@std/yaml'
export interface Author {
name: string
url?: string
orcid?: string
[key: string]: unknown
}
export interface ImageEntry {
file: string
role?: 'cover'
alt: string
caption?: string
license: string | 'UNKNOWN'
authors: Author[] | 'UNKNOWN'
source_url?: string
modifications?: string
[key: string]: unknown
}
export interface Frontmatter {
title: string
slug: string
date: Date
description?: string
image?: string
cover?: { image?: string; alt?: string; caption?: string }
tags?: string[]
draft?: boolean
license?: string
authors?: Author[]
images?: ImageEntry[]
[key: string]: unknown
}
export function parseFrontmatter(md: string): { fm: Frontmatter; body: string } {
const match = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
if (!match) {
throw new Error('Frontmatter: no leading --- / --- block found')
}
const fm = parseYaml(match[1]) as Frontmatter
if (!fm || typeof fm !== 'object') {
throw new Error('Frontmatter: YAML did not produce an object')
}
return { fm, body: match[2] }
}

View File

@ -0,0 +1,45 @@
import { extname, join } from '@std/path'
const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'])
const MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
}
const HUGO_DERIVATIVE = /_hu_[0-9a-f]+\./
export function mimeFromExt(filename: string): string {
return MIME_MAP[extname(filename).toLowerCase()] ?? 'application/octet-stream'
}
export interface ImageFile {
fileName: string
absolutePath: string
data: Uint8Array
mimeType: string
}
export async function collectImages(postDir: string): Promise<ImageFile[]> {
const results: ImageFile[] = []
for await (const entry of Deno.readDir(postDir)) {
if (!entry.isFile) continue
if (HUGO_DERIVATIVE.test(entry.name)) continue
const ext = extname(entry.name).toLowerCase()
if (!IMG_EXTS.has(ext)) continue
const abs = join(postDir, entry.name)
const data = await Deno.readFile(abs)
results.push({
fileName: entry.name,
absolutePath: abs,
data,
mimeType: mimeFromExt(entry.name),
})
}
results.sort((a, b) => a.fileName.localeCompare(b.fileName))
return results
}

106
publish/src/core/log.ts Normal file
View File

@ -0,0 +1,106 @@
export type RunMode = 'diff' | 'force-all' | 'post-single'
export interface PostLog {
slug: string
status: 'success' | 'failed' | 'skipped-draft'
action?: 'new' | 'update'
event_id?: string
relays_ok?: string[]
relays_failed?: string[]
blossom_servers_ok?: string[]
images_uploaded?: number
duration_ms?: number
error?: string
}
export interface RunLog {
run_id: string
started_at: string
ended_at: string
mode: RunMode
posts: PostLog[]
exit_code: number
}
export interface SuccessArgs {
slug: string
action: 'new' | 'update'
eventId: string
relaysOk: string[]
relaysFailed: string[]
blossomServersOk: string[]
imagesUploaded: number
durationMs: number
}
export interface FailedArgs {
slug: string
error: string
durationMs: number
}
export interface LoggerOptions {
mode: RunMode
runId: string
print?: (line: string) => void
now?: () => Date
}
export interface Logger {
postSuccess(args: SuccessArgs): void
postFailed(args: FailedArgs): void
postSkippedDraft(slug: string): void
finalize(exitCode: number): RunLog
writeJson(path: string, summary: RunLog): Promise<void>
}
export function createLogger(opts: LoggerOptions): Logger {
const print = opts.print ?? ((line: string) => console.log(line))
const now = opts.now ?? (() => new Date())
const posts: PostLog[] = []
const startedAt = now().toISOString()
return {
postSuccess(a) {
posts.push({
slug: a.slug,
status: 'success',
action: a.action,
event_id: a.eventId,
relays_ok: a.relaysOk,
relays_failed: a.relaysFailed,
blossom_servers_ok: a.blossomServersOk,
images_uploaded: a.imagesUploaded,
duration_ms: a.durationMs,
})
print(
`${a.slug} (${a.action}) — relays:${a.relaysOk.length}ok/${a.relaysFailed.length}fail — ${a.durationMs}ms`,
)
},
postFailed(a) {
posts.push({
slug: a.slug,
status: 'failed',
error: a.error,
duration_ms: a.durationMs,
})
print(`${a.slug}${a.error}`)
},
postSkippedDraft(slug) {
posts.push({ slug, status: 'skipped-draft' })
print(`- ${slug} (draft, skipped)`)
},
finalize(exitCode) {
return {
run_id: opts.runId,
started_at: startedAt,
ended_at: now().toISOString(),
mode: opts.mode,
posts,
exit_code: exitCode,
}
},
writeJson(path, summary) {
return Deno.writeTextFile(path, JSON.stringify(summary, null, 2))
},
}
}

View File

@ -0,0 +1,29 @@
const IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+=\d+x\d+)?\)/g
function isAbsolute(url: string): boolean {
return /^(https?:)?\/\//i.test(url)
}
export function rewriteImageUrls(md: string, mapping: Map<string, string>): string {
return md.replace(IMG_RE, (full, alt: string, url: string) => {
if (isAbsolute(url)) return full.replace(/\s+=\d+x\d+\)$/, ')')
let decoded: string
try {
decoded = decodeURIComponent(url)
} catch {
decoded = url
}
const target = mapping.get(decoded) ?? mapping.get(url)
if (!target) return full.replace(/\s+=\d+x\d+\)$/, ')')
return `![${alt}](${target})`
})
}
export function resolveCoverUrl(
coverRaw: string | undefined,
mapping: Map<string, string>,
): string | undefined {
if (!coverRaw) return undefined
if (isAbsolute(coverRaw)) return coverRaw
return mapping.get(coverRaw)
}

View File

@ -0,0 +1,37 @@
import { Relay } from 'applesauce-relay'
import { firstValueFrom, timeout } from 'rxjs'
import type { SignedEvent } from './relays.ts'
export interface Outbox {
read: string[]
write: string[]
}
export function parseOutbox(ev: { tags: string[][] }): Outbox {
const read: string[] = []
const write: string[] = []
for (const t of ev.tags) {
if (t[0] !== 'r' || !t[1]) continue
const marker = t[2]
if (marker === 'read') read.push(t[1])
else if (marker === 'write') write.push(t[1])
else {
read.push(t[1])
write.push(t[1])
}
}
return { read, write }
}
export async function loadOutbox(
bootstrapRelay: string,
authorPubkeyHex: string,
): Promise<Outbox> {
const relay = new Relay(bootstrapRelay)
const ev = await firstValueFrom(
relay
.request({ kinds: [10002], authors: [authorPubkeyHex], limit: 1 })
.pipe(timeout({ first: 10_000 })),
) as SignedEvent
return parseOutbox(ev)
}

109
publish/src/core/relays.ts Normal file
View File

@ -0,0 +1,109 @@
import { Relay, RelayPool } from 'applesauce-relay'
import { firstValueFrom, timeout } from 'rxjs'
export interface SignedEvent {
id: string
pubkey: string
created_at: number
kind: number
tags: string[][]
content: string
sig: string
}
export interface PublishResult {
ok: boolean
reason?: string
}
export type PublishFn = (url: string, ev: SignedEvent) => Promise<PublishResult>
export interface PublishOptions {
publishFn?: PublishFn
retries?: number
timeoutMs?: number
backoffMs?: number
}
export interface RelaysReport {
ok: string[]
failed: string[]
}
const defaultPool = new RelayPool()
const defaultPublish: PublishFn = async (url, ev) => {
try {
const relay = defaultPool.relay(url)
const result = await firstValueFrom(relay.publish(ev).pipe(timeout({ first: 10_000 })))
return { ok: result.ok, reason: result.message }
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
}
}
async function publishOne(
url: string,
ev: SignedEvent,
opts: Required<PublishOptions>,
): Promise<boolean> {
const total = opts.retries + 1
for (let i = 0; i < total; i++) {
let timerId: number | undefined
const timeoutPromise = new Promise<PublishResult>((resolve) => {
timerId = setTimeout(() => resolve({ ok: false, reason: 'timeout' }), opts.timeoutMs)
})
const res = await Promise.race([opts.publishFn(url, ev), timeoutPromise])
if (timerId !== undefined) clearTimeout(timerId)
if (res.ok) return true
if (i < total - 1) await new Promise((r) => setTimeout(r, opts.backoffMs * Math.pow(3, i)))
}
return false
}
export async function publishToRelays(
urls: string[],
ev: SignedEvent,
options: PublishOptions = {},
): Promise<RelaysReport> {
const opts: Required<PublishOptions> = {
publishFn: options.publishFn ?? defaultPublish,
retries: options.retries ?? 2,
timeoutMs: options.timeoutMs ?? 10_000,
backoffMs: options.backoffMs ?? 1000,
}
const results = await Promise.all(
urls.map(async (url) => ({ url, ok: await publishOne(url, ev, opts) })),
)
return {
ok: results.filter((r) => r.ok).map((r) => r.url),
failed: results.filter((r) => !r.ok).map((r) => r.url),
}
}
export type ExistingQuery = (url: string, pubkey: string, slug: string) => Promise<boolean>
const defaultExistingQuery: ExistingQuery = async (url, pubkey, slug) => {
try {
const relay = new Relay(url)
const ev = await firstValueFrom(
relay
.request({ kinds: [30023], authors: [pubkey], '#d': [slug], limit: 1 })
.pipe(timeout({ first: 5_000 })),
)
return !!ev
} catch {
return false
}
}
export async function checkExisting(
slug: string,
pubkey: string,
urls: string[],
opts: { query?: ExistingQuery } = {},
): Promise<boolean> {
const query = opts.query ?? defaultExistingQuery
const results = await Promise.all(urls.map((u) => query(u, pubkey, slug)))
return results.some((r) => r)
}

View File

@ -0,0 +1,90 @@
import { NostrConnectSigner, SimpleSigner } from 'applesauce-signers'
import { RelayPool } from 'applesauce-relay'
import type { UnsignedEvent } from './event.ts'
import type { SignedEvent } from './relays.ts'
export interface Signer {
getPublicKey(): Promise<string>
signEvent(ev: UnsignedEvent): Promise<SignedEvent>
}
const signerPool = new RelayPool()
NostrConnectSigner.subscriptionMethod = (relays, filters) => signerPool.req(relays, filters)
NostrConnectSigner.publishMethod = (relays, event) => signerPool.event(relays, event)
// Workaround: amber sendet bei wiederholten connect-requests mit bereits
// bekanntem secret "already connected" oder "no permission". applesauce-
// signers wirft daraufhin unhandled rejections, weil der request intern
// schon aufgelöst wurde. wir schlucken diese benannten fehler prozessweit.
const BENIGN_CONNECT_ERRORS = ['already connected', 'no permission']
function isBenignConnectError(msg: string): boolean {
const lower = msg.toLowerCase()
return BENIGN_CONNECT_ERRORS.some((e) => lower.includes(e))
}
globalThis.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const reason = e.reason
const msg = reason instanceof Error ? reason.message : String(reason)
if (isBenignConnectError(msg)) {
e.preventDefault()
}
})
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
let timerId: number | undefined
const timeoutPromise = new Promise<never>((_r, rej) => {
timerId = setTimeout(() => rej(new Error(`${label} timeout`)), ms)
})
return Promise.race([p, timeoutPromise]).finally(() => {
if (timerId !== undefined) clearTimeout(timerId)
}) as Promise<T>
}
export interface CreateSignerOptions {
clientSecretHex?: string
}
export async function createBunkerSigner(
bunkerUrl: string,
options: CreateSignerOptions = {},
): Promise<Signer> {
const { remote, relays, secret } = NostrConnectSigner.parseBunkerURI(bunkerUrl)
console.log(` signer: setup (remote=${remote.slice(0, 8)}…, relays=${relays.length})`)
// Stabile client-identität: ohne festen CLIENT_SECRET_HEX erzeugt
// applesauce pro lauf einen zufälligen key, und amber sieht jeden lauf
// als neue app → permissions greifen nie. mit festem key bleibt die
// identität über läufe erhalten.
const clientSigner = options.clientSecretHex
? SimpleSigner.fromKey(options.clientSecretHex)
: undefined
const signer = new NostrConnectSigner({ relays, remote, signer: clientSigner })
const clientPubkey = await signer.signer.getPublicKey()
console.log(` signer: client-pubkey=${clientPubkey.slice(0, 8)}`)
// connect() beim ersten mal nötig (damit amber die app registriert);
// bei späteren runs ist amber schon gepaired mit diesem client-pubkey
// und antwortet auf get_public_key / sign_event ohne erneuten connect.
// wir versuchen connect, schlucken benign errors, und fallen-back auf
// manuelles open().
try {
await withTimeout(signer.connect(secret), 60_000, 'Bunker connect')
console.log(' signer: connect ok')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isBenignConnectError(msg)) throw err
console.log(` signer: connect benign "${msg}", fallback to open+force`)
await signer.open()
;(signer as unknown as { isConnected: boolean }).isConnected = true
}
console.log(' signer: getPublicKey…')
const pubkey = await withTimeout(signer.getPublicKey(), 30_000, 'Bunker getPublicKey')
console.log(` signer: pubkey ok (${pubkey.slice(0, 8)}…)`)
return {
getPublicKey: () => Promise.resolve(pubkey),
signEvent: async (ev: UnsignedEvent) => {
const signed = await withTimeout(signer.signEvent(ev), 30_000, 'Bunker signEvent')
return signed as SignedEvent
},
}
}

View File

@ -0,0 +1,30 @@
import type { Frontmatter } from './frontmatter.ts'
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/
const DATE_STRING_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/
export function validateSlug(slug: string): void {
if (!SLUG_RE.test(slug)) {
throw new Error(`invalid slug: "${slug}" (must match ${SLUG_RE})`)
}
}
export function validatePost(fm: Frontmatter): void {
if (!fm.title || typeof fm.title !== 'string') {
throw new Error('missing/invalid title')
}
if (!fm.slug || typeof fm.slug !== 'string') {
throw new Error('missing/invalid slug')
}
validateSlug(fm.slug)
// Coerce string-dates (YAML `date: "2023-02-26"`) in-place zu Date.
// Native YAML-Dates (`date: 2023-02-26` ohne quotes) kommen bereits als
// Date-instanz aus dem yaml-parser.
if (typeof fm.date === 'string' && DATE_STRING_RE.test(fm.date)) {
const coerced = new Date(fm.date)
if (!isNaN(coerced.getTime())) fm.date = coerced
}
if (!(fm.date instanceof Date) || isNaN(fm.date.getTime())) {
throw new Error('missing/invalid date (expected YAML date or ISO-string)')
}
}

View File

@ -0,0 +1,67 @@
import type { Config } from '../core/config.ts'
import { createBunkerSigner } from '../core/signer.ts'
import { loadOutbox } from '../core/outbox.ts'
import { loadBlossomServers } from '../core/blossom-list.ts'
export interface CheckResult {
ok: boolean
issues: string[]
}
export async function runCheck(config: Config): Promise<CheckResult> {
const issues: string[] = []
try {
const signer = await createBunkerSigner(config.bunkerUrl, {
clientSecretHex: config.clientSecretHex,
})
const pk = await signer.getPublicKey()
if (pk !== config.authorPubkeyHex) {
issues.push(
`bunker-pubkey (${pk}) matcht AUTHOR_PUBKEY_HEX (${config.authorPubkeyHex}) nicht`,
)
}
} catch (err) {
issues.push(`bunker-ping fehlgeschlagen: ${err instanceof Error ? err.message : err}`)
}
try {
const outbox = await loadOutbox(config.bootstrapRelay, config.authorPubkeyHex)
if (outbox.write.length === 0) {
issues.push('kind:10002 hat keine write-relays — publiziere zuerst ein gültiges Event')
}
} catch (err) {
issues.push(`kind:10002 laden: ${err instanceof Error ? err.message : err}`)
}
try {
const servers = await loadBlossomServers(config.bootstrapRelay, config.authorPubkeyHex)
if (servers.length === 0) {
issues.push('kind:10063 hat keine server — publiziere zuerst ein gültiges Event')
} else {
for (const server of servers) {
try {
const resp = await fetch(server + '/', { method: 'HEAD' })
if (!resp.ok && resp.status !== 405) {
issues.push(`blossom-server ${server}: HTTP ${resp.status}`)
}
} catch (err) {
issues.push(`blossom-server ${server}: ${err instanceof Error ? err.message : err}`)
}
}
}
} catch (err) {
issues.push(`kind:10063 laden: ${err instanceof Error ? err.message : err}`)
}
return { ok: issues.length === 0, issues }
}
export function printCheckResult(result: CheckResult): void {
if (result.ok) {
console.log('✓ pre-flight ok')
return
}
console.error('✗ pre-flight issues:')
for (const i of result.issues) console.error(` - ${i}`)
}

View File

@ -0,0 +1,126 @@
import { join } from '@std/path'
import { type Frontmatter } from '../core/frontmatter.ts'
import { validatePost } from '../core/validation.ts'
import { buildKind30023, type UnsignedEvent } from '../core/event.ts'
import { resolveCoverUrl, rewriteImageUrls } from '../core/markdown.ts'
import type { ImageFile } from '../core/image-collector.ts'
import type { RelaysReport, SignedEvent } from '../core/relays.ts'
import type { UploadReport } from '../core/blossom.ts'
export interface PostDeps {
readPostFile(path: string): Promise<{ fm: Frontmatter; body: string }>
collectImages(postDir: string): Promise<ImageFile[]>
uploadBlossom(args: {
data: Uint8Array
fileName: string
mimeType: string
}): Promise<UploadReport>
sign(ev: UnsignedEvent): Promise<SignedEvent>
publish(ev: SignedEvent, relays: string[]): Promise<RelaysReport>
checkExisting(slug: string, relays: string[]): Promise<boolean>
}
export interface ProcessArgs {
postDir: string
writeRelays: string[]
blossomServers: string[]
pubkeyHex: string
clientTag: string
minRelayAcks: number
deps: PostDeps
now?: () => number
}
export interface ProcessResult {
status: 'success' | 'failed' | 'skipped-draft'
action?: 'new' | 'update'
slug: string
eventId?: string
relaysOk: string[]
relaysFailed: string[]
blossomServersOk: string[]
imagesUploaded: number
durationMs: number
error?: string
}
export async function processPost(args: ProcessArgs): Promise<ProcessResult> {
const started = performance.now()
const now = args.now ?? (() => Math.floor(Date.now() / 1000))
let slug = '?'
try {
const { fm, body } = await args.deps.readPostFile(join(args.postDir, 'index.md'))
validatePost(fm)
slug = fm.slug
if (fm.draft === true) {
return {
status: 'skipped-draft',
slug,
relaysOk: [],
relaysFailed: [],
blossomServersOk: [],
imagesUploaded: 0,
durationMs: Math.round(performance.now() - started),
}
}
const images = await args.deps.collectImages(args.postDir)
const blossomOkServers = new Set<string>()
const mapping = new Map<string, string>()
for (const img of images) {
const rep = await args.deps.uploadBlossom({
data: img.data,
fileName: img.fileName,
mimeType: img.mimeType,
})
for (const s of rep.ok) blossomOkServers.add(s)
mapping.set(img.fileName, rep.primaryUrl)
}
const rewrittenBody = rewriteImageUrls(body, mapping)
const coverRaw = fm.cover?.image ?? fm.image
const coverUrl = resolveCoverUrl(coverRaw, mapping)
const unsigned = buildKind30023({
fm,
rewrittenBody,
coverUrl,
pubkeyHex: args.pubkeyHex,
clientTag: args.clientTag,
nowSeconds: now(),
})
const existing = await args.deps.checkExisting(fm.slug, args.writeRelays)
const signed = await args.deps.sign(unsigned)
const pubRep = await args.deps.publish(signed, args.writeRelays)
if (pubRep.ok.length < args.minRelayAcks) {
throw new Error(
`insufficient relays acked (${pubRep.ok.length} < ${args.minRelayAcks})`,
)
}
return {
status: 'success',
action: existing ? 'update' : 'new',
slug,
eventId: signed.id,
relaysOk: pubRep.ok,
relaysFailed: pubRep.failed,
blossomServersOk: [...blossomOkServers],
imagesUploaded: images.length,
durationMs: Math.round(performance.now() - started),
}
} catch (err) {
return {
status: 'failed',
slug,
relaysOk: [],
relaysFailed: [],
blossomServersOk: [],
imagesUploaded: 0,
durationMs: Math.round(performance.now() - started),
error: err instanceof Error ? err.message : String(err),
}
}
}

View File

@ -0,0 +1,24 @@
import { parseFrontmatter } from '../core/frontmatter.ts'
import { validatePost } from '../core/validation.ts'
export interface ValidateResult {
ok: boolean
slug?: string
error?: string
}
export async function validatePostFile(path: string): Promise<ValidateResult> {
let text: string
try {
text = await Deno.readTextFile(path)
} catch (err) {
return { ok: false, error: `cannot read ${path}: ${err instanceof Error ? err.message : err}` }
}
try {
const { fm } = parseFrontmatter(text)
validatePost(fm)
return { ok: true, slug: fm.slug }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
}

Some files were not shown because too many files have changed in this diff Show More