diff --git a/docs/redaktion-bild-metadaten.md b/docs/redaktion-bild-metadaten.md new file mode 100644 index 0000000..c1f131c --- /dev/null +++ b/docs/redaktion-bild-metadaten.md @@ -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 120–122 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) diff --git a/docs/superpowers/plans/2026-04-16-publish-pipeline.md b/docs/superpowers/plans/2026-04-16-publish-pipeline.md new file mode 100644 index 0000000..bb2f2df --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-publish-pipeline.md @@ -0,0 +1,2909 @@ +# Publish-Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eine Deno-basierte Toolchain bauen, die Markdown-Posts aus `content/posts/*/index.md` in signierte `kind:30023`-Events umwandelt, alle Bilder zu Blossom hochlädt und die Events zu Public-Relays publisht — sowohl lokal per CLI als auch automatisch per GitHub Action beim Push auf `main`. + +**Architecture:** Gemeinsame Library (`src/core/`) + CLI-Entrypoint (`src/cli.ts`) + Subcommands (`src/subcommands/`). Signatur via NIP-46-Bunker (Amber), Config aus Nostr (`kind:10002` Relays, `kind:10063` Blossom), Change-Detection via Git-Diff. State-los im Repo, keine Lock-Files. **Einheitlicher Upload-Pfad:** alle Bilder (Alt- wie Neuposts) landen auf Blossom. Kein rsync, kein Legacy-Pfad. + +**Blaupausen-Prinzip:** Der Code enthält **keine** projekt-spezifischen Konstanten. Alle Werte (Pubkey, Relay, Content-Pfad, Client-Tag) kommen aus Env-Variablen. `publish/` ist als eigenständiges Verzeichnis gedacht, das in andere Nostr-Repos per Submodule oder Template übernommen werden kann. + +**Tech Stack:** Deno 2.x, TypeScript, `applesauce-signers` (NIP-46), `applesauce-relay` (RxJS), `nostr-tools` (Event-Bau/Verify), `@std/yaml`, `@std/cli`, `@std/fs`, `@std/path`, `@std/testing`. Zielordner: `publish/` auf Repo-Root. + +--- + +## Phase 1 — Projekt-Setup + +### Task 1: Deno-Projekt-Grundgerüst + +**Files:** +- Create: `publish/deno.jsonc` +- Create: `publish/.gitignore` +- Create: `publish/.env.example` +- Create: `publish/README.md` + +**Env-Handling:** Die Pipeline liest ausschließlich aus Env-Variablen — keine hardcoded Projekt-Konstanten im Code. Lade-Reihenfolge (Deno 2.x lädt die erste existierende Datei): + +1. `publish/.env` — lokale Publish-Config (gitignored, Template: `publish/.env.example`). +2. Fallback: `../.env.local` im Repo-Root, falls vorhanden (für Repos, die schon eine `.env.local` pflegen). +3. In CI: GitHub-Actions-Secrets werden als Prozess-Env injiziert. + +Für dieses Projekt existiert bereits `../.env.local` mit `BUNKER_URL`, `AUTHOR_PUBKEY_HEX`, `BOOTSTRAP_RELAY`. Die Pipeline-`deno.jsonc` nutzt primär `../.env.local` per `--env-file`. In einem Fremd-Repo, das `publish/` einbindet, würde stattdessen `publish/.env` angelegt und der `--env-file`-Pfad angepasst (oder `.env.example` kopiert). + +- [ ] **Step 1: Verzeichnis anlegen und `deno.jsonc` schreiben** + +`publish/deno.jsonc`: + +```jsonc +{ + "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-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"] + } + } +} +``` + +- [ ] **Step 2: `publish/.gitignore` schreiben** + +``` +.env +logs/ +``` + +- [ ] **Step 3: `publish/.env.example` schreiben (Template für Fremd-Repos)** + +``` +# ==== PFLICHT ==== + +# NIP-46-Bunker-URL vom Signer (Amber, nak bunker, nsite.run, …) +BUNKER_URL=bunker://?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", ""]-Tag in jedes kind:30023-Event eingetragen. +# Hilft bei der Zuordnung der Event-Herkunft. Default: joerglohrerde-publish +CLIENT_TAG=joerglohrerde-publish + +# Minimal geforderte Relay-ACKs pro Post (default: 2) +MIN_RELAY_ACKS=2 +``` + +- [ ] **Step 4: `publish/README.md` schreiben** + +```markdown +# 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 ` — nur ein Post. +- `deno task publish --dry-run` — zeigt, was publiziert würde, ohne Uploads. +- `deno task validate-post content/posts//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. +``` + +- [ ] **Step 5: Verifikation + Commit** + +Run: `cd publish && deno fmt --check deno.jsonc` +Expected: PASS (kein Output) + +```bash +git add publish/deno.jsonc publish/.gitignore publish/.env.example publish/README.md +git commit -m "publish(task 1): deno-grundgerüst (deno.jsonc, .env.example, readme)" +``` + +--- + +### Task 2: Config-Modul mit Env-Loader + +**Files:** +- Create: `publish/src/core/config.ts` +- Create: `publish/tests/config_test.ts` + +- [ ] **Step 1: Test schreiben** + +`publish/tests/config_test.ts`: + +```typescript +import { assertEquals, assertThrows } from '@std/assert' +import { loadConfig } from '../src/core/config.ts' + +const REQUIRED = { + BUNKER_URL: 'bunker://abc?relay=wss://r.example&secret=s', + AUTHOR_PUBKEY_HEX: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41', + BOOTSTRAP_RELAY: 'wss://relay.damus.io', +} + +Deno.test('loadConfig: liest alle pflicht-keys aus env', () => { + const cfg = loadConfig((k) => REQUIRED[k as keyof typeof REQUIRED]) + assertEquals(cfg.bunkerUrl, REQUIRED.BUNKER_URL) + assertEquals(cfg.authorPubkeyHex, REQUIRED.AUTHOR_PUBKEY_HEX) + assertEquals(cfg.bootstrapRelay, REQUIRED.BOOTSTRAP_RELAY) +}) + +Deno.test('loadConfig: liefert defaults für optionale keys', () => { + const cfg = loadConfig((k) => REQUIRED[k as keyof typeof REQUIRED]) + assertEquals(cfg.contentRoot, '../content/posts') + assertEquals(cfg.clientTag, 'nostr-publish-pipeline') + assertEquals(cfg.minRelayAcks, 2) +}) + +Deno.test('loadConfig: optionale keys können überschrieben werden', () => { + const env = { + ...REQUIRED, + CONTENT_ROOT: '../blog', + CLIENT_TAG: 'my-site', + MIN_RELAY_ACKS: '3', + } + const cfg = loadConfig((k) => env[k as keyof typeof env]) + assertEquals(cfg.contentRoot, '../blog') + assertEquals(cfg.clientTag, 'my-site') + assertEquals(cfg.minRelayAcks, 3) +}) + +Deno.test('loadConfig: wirft bei fehlender pflicht-variable', () => { + assertThrows(() => loadConfig(() => undefined), Error, 'BUNKER_URL') +}) + +Deno.test('loadConfig: validiert pubkey-format (64 hex)', () => { + const env = { ...REQUIRED, AUTHOR_PUBKEY_HEX: 'zzz' } + assertThrows( + () => loadConfig((k) => env[k as keyof typeof env]), + Error, + 'AUTHOR_PUBKEY_HEX', + ) +}) + +Deno.test('loadConfig: MIN_RELAY_ACKS muss positiv sein', () => { + const env = { ...REQUIRED, MIN_RELAY_ACKS: '0' } + assertThrows( + () => loadConfig((k) => env[k as keyof typeof env]), + Error, + 'MIN_RELAY_ACKS', + ) +}) +``` + +- [ ] **Step 2: Test lässt sich nicht laufen (Modul fehlt)** + +Run: `cd publish && deno test tests/config_test.ts` +Expected: FAIL — "Module not found" + +- [ ] **Step 3: `publish/src/core/config.ts` schreiben** + +```typescript +export interface Config { + bunkerUrl: string + authorPubkeyHex: string + bootstrapRelay: string + contentRoot: string + clientTag: string + minRelayAcks: number +} + +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: 'nostr-publish-pipeline', + MIN_RELAY_ACKS: '2', +} + +export function loadConfig(read: EnvReader = (k) => Deno.env.get(k)): Config { + const missing: string[] = [] + const values: Record = {} + 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}"`) + } + 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, + } +} +``` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `cd publish && deno test tests/config_test.ts` +Expected: PASS (6 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/config.ts publish/tests/config_test.ts +git commit -m "publish(task 2): config-loader mit env-validation" +``` + +--- + +## Phase 2 — Pure Transformationen (Frontmatter, Markdown, Events) + +### Task 3: Frontmatter-Parser + +**Files:** +- Create: `publish/src/core/frontmatter.ts` +- Create: `publish/tests/frontmatter_test.ts` +- Create: `publish/tests/fixtures/sample-post.md` + +- [ ] **Step 1: Fixture `publish/tests/fixtures/sample-post.md` anlegen** + +```markdown +--- +layout: post +title: "Sample Title" +slug: "sample-slug" +description: "A short summary" +image: cover.png +cover: + image: cover.png + alt: "Alt text" +date: 2024-01-15 +tags: ["Foo", "Bar"] +draft: false +--- + +Body content here. + +![pic](image1.jpg) +``` + +- [ ] **Step 2: Test schreiben** + +`publish/tests/frontmatter_test.ts`: + +```typescript +import { assertEquals, assertThrows } from '@std/assert' +import { parseFrontmatter } from '../src/core/frontmatter.ts' + +Deno.test('parseFrontmatter: zerlegt Frontmatter und Body', async () => { + const md = await Deno.readTextFile('./tests/fixtures/sample-post.md') + const { fm, body } = parseFrontmatter(md) + assertEquals(fm.title, 'Sample Title') + assertEquals(fm.slug, 'sample-slug') + assertEquals(fm.date instanceof Date, true) + assertEquals(fm.tags, ['Foo', 'Bar']) + assertEquals(fm.cover?.image, 'cover.png') + assertEquals(body.trim().startsWith('Body content here.'), true) +}) + +Deno.test('parseFrontmatter: wirft bei fehlendem Frontmatter', () => { + assertThrows(() => parseFrontmatter('no frontmatter here'), Error, 'Frontmatter') +}) + +Deno.test('parseFrontmatter: wirft bei unvollständigem Frontmatter', () => { + assertThrows(() => parseFrontmatter('---\ntitle: x\n'), Error, 'Frontmatter') +}) + +Deno.test('parseFrontmatter: erhält Leerzeichen in String-Werten', () => { + const md = '---\ntitle: "Hello World"\nslug: "h-w"\ndate: 2024-01-01\n---\n\nbody' + const { fm } = parseFrontmatter(md) + assertEquals(fm.title, 'Hello World') +}) +``` + +- [ ] **Step 3: Test verifiziert FAIL** + +Run: `cd publish && deno test tests/frontmatter_test.ts` +Expected: FAIL — Module not found + +- [ ] **Step 4: `publish/src/core/frontmatter.ts` schreiben** + +```typescript +import { parse as parseYaml } from '@std/yaml' + +export interface Frontmatter { + title: string + slug: string + date: Date + description?: string + image?: string + cover?: { image?: string; alt?: string; caption?: string } + tags?: string[] + draft?: boolean + [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] } +} +``` + +- [ ] **Step 5: Tests laufen** + +Run: `cd publish && deno test tests/frontmatter_test.ts` +Expected: PASS (4 Tests) + +- [ ] **Step 6: Commit** + +```bash +git add publish/src/core/frontmatter.ts publish/tests/frontmatter_test.ts publish/tests/fixtures/sample-post.md +git commit -m "publish(task 3): frontmatter-parser mit yaml + body-split" +``` + +--- + +### Task 4: Slug-Validator und Post-Validator + +**Files:** +- Create: `publish/src/core/validation.ts` +- Create: `publish/tests/validation_test.ts` + +- [ ] **Step 1: Test schreiben** + +`publish/tests/validation_test.ts`: + +```typescript +import { assertEquals, assertThrows } from '@std/assert' +import { validatePost, validateSlug } from '../src/core/validation.ts' +import type { Frontmatter } from '../src/core/frontmatter.ts' + +Deno.test('validateSlug: akzeptiert lowercase/digits/hyphen', () => { + validateSlug('abc-123') + validateSlug('a') + validateSlug('dezentrale-oep-oer') +}) + +Deno.test('validateSlug: lehnt Großbuchstaben ab', () => { + assertThrows(() => validateSlug('Abc'), Error, 'slug') +}) + +Deno.test('validateSlug: lehnt Unterstriche/Leerzeichen ab', () => { + assertThrows(() => validateSlug('a_b'), Error, 'slug') + assertThrows(() => validateSlug('a b'), Error, 'slug') +}) + +Deno.test('validateSlug: lehnt führenden Bindestrich ab', () => { + assertThrows(() => validateSlug('-abc'), Error, 'slug') +}) + +Deno.test('validatePost: ok bei vollständigem Frontmatter', () => { + const fm: Frontmatter = { + title: 'T', + slug: 'ok-slug', + date: new Date('2024-01-01'), + } + validatePost(fm) +}) + +Deno.test('validatePost: fehlt title', () => { + const fm = { slug: 'ok', date: new Date() } as unknown as Frontmatter + assertThrows(() => validatePost(fm), Error, 'title') +}) + +Deno.test('validatePost: date muss Date sein', () => { + const fm = { title: 'T', slug: 'ok', date: 'not-a-date' } as unknown as Frontmatter + assertThrows(() => validatePost(fm), Error, 'date') +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/validation_test.ts` +Expected: FAIL — Module not found + +- [ ] **Step 3: `publish/src/core/validation.ts` schreiben** + +```typescript +import type { Frontmatter } from './frontmatter.ts' + +const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/ + +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) + if (!(fm.date instanceof Date) || isNaN(fm.date.getTime())) { + throw new Error('missing/invalid date (expected YAML date)') + } +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/validation_test.ts` +Expected: PASS (7 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/validation.ts publish/tests/validation_test.ts +git commit -m "publish(task 4): slug- und post-validation" +``` + +--- + +### Task 5: Markdown-Bild-URL-Rewriter + +**Files:** +- Create: `publish/src/core/markdown.ts` +- Create: `publish/tests/markdown_test.ts` + +- [ ] **Step 1: Test schreiben** + +`publish/tests/markdown_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { rewriteImageUrls } from '../src/core/markdown.ts' + +Deno.test('rewriteImageUrls: ersetzt ![alt](file) durch Mapping', () => { + const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']]) + const input = '![cat](cat.png)' + assertEquals(rewriteImageUrls(input, mapping), '![cat](https://blossom.example/hash.png)') +}) + +Deno.test('rewriteImageUrls: absolute URL bleibt unverändert', () => { + const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']]) + const input = '![cat](https://other.com/cat.png)' + assertEquals(rewriteImageUrls(input, mapping), input) +}) + +Deno.test('rewriteImageUrls: entfernt =WxH-Suffix', () => { + const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']]) + const input = '![cat](cat.png =300x200)' + assertEquals(rewriteImageUrls(input, mapping), '![cat](https://blossom.example/hash.png)') +}) + +Deno.test('rewriteImageUrls: bild-in-link [![alt](file)](link)', () => { + const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']]) + const input = '[![cat](cat.png)](https://target.example.com)' + assertEquals( + rewriteImageUrls(input, mapping), + '[![cat](https://blossom.example/hash.png)](https://target.example.com)', + ) +}) + +Deno.test('rewriteImageUrls: mehrere Bilder im Text', () => { + const mapping = new Map([ + ['a.png', 'https://bl/a-hash.png'], + ['b.jpg', 'https://bl/b-hash.jpg'], + ]) + const input = 'Text ![a](a.png) more ![b](b.jpg) end' + assertEquals( + rewriteImageUrls(input, mapping), + 'Text ![a](https://bl/a-hash.png) more ![b](https://bl/b-hash.jpg) end', + ) +}) + +Deno.test('rewriteImageUrls: lässt unbekannte Dateinamen stehen', () => { + const mapping = new Map([['cat.png', 'https://bl/c.png']]) + const input = '![x](missing.jpg)' + assertEquals(rewriteImageUrls(input, mapping), input) +}) + +Deno.test('rewriteImageUrls: URL-Dekodierung für Leerzeichen-Namen', () => { + const mapping = new Map([['file with spaces.png', 'https://bl/hash.png']]) + const input = '![x](file%20with%20spaces.png)' + assertEquals(rewriteImageUrls(input, mapping), '![x](https://bl/hash.png)') +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/markdown_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/core/markdown.ts` schreiben** + +```typescript +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 { + 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 | undefined { + if (!coverRaw) return undefined + if (isAbsolute(coverRaw)) return coverRaw + return mapping.get(coverRaw) +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/markdown_test.ts` +Expected: PASS (7 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/markdown.ts publish/tests/markdown_test.ts +git commit -m "publish(task 5): markdown bild-url-rewriter (mapping-basiert, =WxH-strip)" +``` + +--- + +### Task 6: `buildKind30023`-Event-Builder + +**Files:** +- Create: `publish/src/core/event.ts` +- Create: `publish/tests/event_test.ts` + +- [ ] **Step 1: Test schreiben** + +`publish/tests/event_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { buildKind30023 } from '../src/core/event.ts' +import type { Frontmatter } from '../src/core/frontmatter.ts' + +const PUBKEY = '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41' + +Deno.test('buildKind30023: minimaler Post liefert alle Pflicht-Tags', () => { + const fm: Frontmatter = { + title: 'Hello', + slug: 'hello', + date: new Date('2024-01-15T00:00:00Z'), + } + const ev = buildKind30023({ + fm, + rewrittenBody: 'body text', + coverUrl: undefined, + pubkeyHex: PUBKEY, + clientTag: 'test-client', + nowSeconds: 1_700_000_000, + }) + assertEquals(ev.kind, 30023) + assertEquals(ev.pubkey, PUBKEY) + assertEquals(ev.created_at, 1_700_000_000) + assertEquals(ev.content, 'body text') + const tags = ev.tags + assertEquals(tags.find((t) => t[0] === 'd'), ['d', 'hello']) + assertEquals(tags.find((t) => t[0] === 'title'), ['title', 'Hello']) + assertEquals( + tags.find((t) => t[0] === 'published_at')?.[1], + String(Math.floor(Date.UTC(2024, 0, 15) / 1000)), + ) + assertEquals(tags.find((t) => t[0] === 'client'), ['client', 'test-client']) +}) + +Deno.test('buildKind30023: mapping summary / image / tags', () => { + const fm: Frontmatter = { + title: 'T', + slug: 's', + date: new Date('2024-01-01'), + description: 'Summary text', + tags: ['Foo', 'Bar Baz'], + } + const ev = buildKind30023({ + fm, + rewrittenBody: 'b', + coverUrl: 'https://bl.example/cover-hash.png', + pubkeyHex: PUBKEY, + clientTag: 'x', + nowSeconds: 1, + }) + assertEquals(ev.tags.find((t) => t[0] === 'summary'), ['summary', 'Summary text']) + assertEquals(ev.tags.find((t) => t[0] === 'image'), ['image', 'https://bl.example/cover-hash.png']) + assertEquals( + ev.tags.filter((t) => t[0] === 't'), + [['t', 'Foo'], ['t', 'Bar Baz']], + ) +}) + +Deno.test('buildKind30023: ohne coverUrl kein image-tag', () => { + const fm: Frontmatter = { + title: 'T', + slug: 's', + date: new Date('2024-01-01'), + } + const ev = buildKind30023({ + fm, + rewrittenBody: 'b', + coverUrl: undefined, + pubkeyHex: PUBKEY, + clientTag: 'x', + nowSeconds: 1, + }) + assertEquals(ev.tags.some((t) => t[0] === 'image'), false) +}) + +Deno.test('buildKind30023: leerer clientTag wird weggelassen', () => { + const fm: Frontmatter = { + title: 'T', + slug: 's', + date: new Date('2024-01-01'), + } + const ev = buildKind30023({ + fm, + rewrittenBody: 'b', + coverUrl: undefined, + pubkeyHex: PUBKEY, + clientTag: '', + nowSeconds: 1, + }) + assertEquals(ev.tags.some((t) => t[0] === 'client'), false) +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/event_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/core/event.ts` schreiben** + +```typescript +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 +} + +export function buildKind30023(args: BuildArgs): UnsignedEvent { + const { fm, rewrittenBody, coverUrl, pubkeyHex, clientTag, nowSeconds } = 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]) + return { + kind: 30023, + pubkey: pubkeyHex, + created_at: nowSeconds, + tags, + content: rewrittenBody, + } +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/event_test.ts` +Expected: PASS (4 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/event.ts publish/tests/event_test.ts +git commit -m "publish(task 6): kind:30023 event-builder mit tag-mapping" +``` + +--- + +## Phase 3 — Nostr-Infrastruktur (Relays, Signer) + +### Task 7: Relay-Pool-Wrapper (publish) + +**Files:** +- Create: `publish/src/core/relays.ts` +- Create: `publish/tests/relays_test.ts` + +- [ ] **Step 1: Test schreiben (mit injizierter publish-Funktion)** + +`publish/tests/relays_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { publishToRelays } from '../src/core/relays.ts' + +Deno.test('publishToRelays: meldet OK-Antworten je relay', async () => { + const injected = async (url: string, _ev: unknown) => { + if (url.includes('fail')) return { ok: false, reason: 'nope' } + return { ok: true } + } + const result = await publishToRelays( + ['wss://ok1.example', 'wss://ok2.example', 'wss://fail.example'], + { kind: 1, pubkey: 'p', created_at: 1, tags: [], content: 'x', id: 'i', sig: 's' }, + { publishFn: injected, retries: 0, timeoutMs: 100 }, + ) + assertEquals(result.ok.sort(), ['wss://ok1.example', 'wss://ok2.example']) + assertEquals(result.failed, ['wss://fail.example']) +}) + +Deno.test('publishToRelays: retry bei Fehler', async () => { + let attempts = 0 + const injected = async () => { + attempts++ + if (attempts < 2) return { ok: false, reason: 'transient' } + return { ok: true } + } + const result = await publishToRelays( + ['wss://flaky.example'], + { kind: 1, pubkey: 'p', created_at: 1, tags: [], content: 'x', id: 'i', sig: 's' }, + { publishFn: injected, retries: 1, timeoutMs: 100, backoffMs: 1 }, + ) + assertEquals(result.ok, ['wss://flaky.example']) + assertEquals(attempts, 2) +}) + +Deno.test('publishToRelays: timeout → failed', async () => { + const injected = () => + new Promise<{ ok: boolean }>((resolve) => setTimeout(() => resolve({ ok: true }), 500)) + const result = await publishToRelays( + ['wss://slow.example'], + { kind: 1, pubkey: 'p', created_at: 1, tags: [], content: 'x', id: 'i', sig: 's' }, + { publishFn: injected, retries: 0, timeoutMs: 10 }, + ) + assertEquals(result.failed, ['wss://slow.example']) +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/relays_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/core/relays.ts` schreiben** + +```typescript +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 + +export interface PublishOptions { + publishFn?: PublishFn + retries?: number + timeoutMs?: number + backoffMs?: number +} + +export interface RelaysReport { + ok: string[] + failed: string[] +} + +const defaultPool = new RelayPool((url) => new Relay(url)) + +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, +): Promise { + const total = opts.retries + 1 + for (let i = 0; i < total; i++) { + const attempt = Promise.race([ + opts.publishFn(url, ev), + new Promise((resolve) => + setTimeout(() => resolve({ ok: false, reason: 'timeout' }), opts.timeoutMs) + ), + ]) + const res = await attempt + 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 { + const opts: Required = { + 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 + +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 { + const query = opts.query ?? defaultExistingQuery + const results = await Promise.all(urls.map((u) => query(u, pubkey, slug))) + return results.some((r) => r) +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/relays_test.ts` +Expected: PASS (3 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/relays.ts publish/tests/relays_test.ts +git commit -m "publish(task 7): relay-pool-wrapper (publish + checkExisting)" +``` + +--- + +### Task 8: Outbox-Relay-Loader (kind:10002) + +**Files:** +- Create: `publish/src/core/outbox.ts` +- Create: `publish/tests/outbox_test.ts` + +- [ ] **Step 1: Test schreiben** + +`publish/tests/outbox_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { parseOutbox } from '../src/core/outbox.ts' + +Deno.test('parseOutbox: r-tags ohne marker → beide', () => { + const ev = { + kind: 10002, + tags: [ + ['r', 'wss://damus'], + ['r', 'wss://nos'], + ], + } + assertEquals(parseOutbox(ev), { + read: ['wss://damus', 'wss://nos'], + write: ['wss://damus', 'wss://nos'], + }) +}) + +Deno.test('parseOutbox: marker read ignoriert schreib-nutzung', () => { + const ev = { + kind: 10002, + tags: [ + ['r', 'wss://r-only', 'read'], + ['r', 'wss://w-only', 'write'], + ['r', 'wss://both'], + ], + } + assertEquals(parseOutbox(ev), { + read: ['wss://r-only', 'wss://both'], + write: ['wss://w-only', 'wss://both'], + }) +}) + +Deno.test('parseOutbox: ignoriert andere tag-namen', () => { + const ev = { + kind: 10002, + tags: [ + ['r', 'wss://x'], + ['p', 'someone'], + ], + } + assertEquals(parseOutbox(ev), { read: ['wss://x'], write: ['wss://x'] }) +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/outbox_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/core/outbox.ts` schreiben** + +```typescript +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 { + 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) +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/outbox_test.ts` +Expected: PASS (3 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/outbox.ts publish/tests/outbox_test.ts +git commit -m "publish(task 8): outbox-relay-loader (kind:10002 parser + fetcher)" +``` + +--- + +### Task 9: Blossom-Server-Liste-Loader (kind:10063) + +**Files:** +- Create: `publish/src/core/blossom-list.ts` +- Create: `publish/tests/blossom-list_test.ts` + +- [ ] **Step 1: Test schreiben** + +`publish/tests/blossom-list_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { parseBlossomServers } from '../src/core/blossom-list.ts' + +Deno.test('parseBlossomServers: extrahiert server-urls in reihenfolge', () => { + const ev = { + kind: 10063, + tags: [ + ['server', 'https://a.example'], + ['server', 'https://b.example'], + ['other', 'ignored'], + ], + } + assertEquals(parseBlossomServers(ev), ['https://a.example', 'https://b.example']) +}) + +Deno.test('parseBlossomServers: leere liste bei fehlenden tags', () => { + assertEquals(parseBlossomServers({ kind: 10063, tags: [] }), []) +}) + +Deno.test('parseBlossomServers: entfernt trailing-slash normalisierung', () => { + const ev = { + kind: 10063, + tags: [ + ['server', 'https://a.example/'], + ], + } + assertEquals(parseBlossomServers(ev), ['https://a.example']) +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/blossom-list_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/core/blossom-list.ts` schreiben** + +```typescript +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 { + 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) +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/blossom-list_test.ts` +Expected: PASS (3 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/blossom-list.ts publish/tests/blossom-list_test.ts +git commit -m "publish(task 9): blossom-server-liste-loader (kind:10063)" +``` + +--- + +### Task 10: NIP-46 Bunker-Signer-Wrapper + +**Files:** +- Create: `publish/src/core/signer.ts` + +- [ ] **Step 1: Implementierung schreiben** + +`publish/src/core/signer.ts`: + +```typescript +import { Nip46Signer } from 'applesauce-signers' +import type { UnsignedEvent } from './event.ts' +import type { SignedEvent } from './relays.ts' + +export interface Signer { + getPublicKey(): Promise + signEvent(ev: UnsignedEvent): Promise +} + +export async function createBunkerSigner(bunkerUrl: string): Promise { + const signer = Nip46Signer.fromBunkerURI(bunkerUrl) + const pubkey = await Promise.race([ + signer.getPublicKey(), + new Promise((_r, rej) => setTimeout(() => rej(new Error('Bunker ping timeout')), 30_000)), + ]) + return { + getPublicKey: () => Promise.resolve(pubkey), + signEvent: async (ev: UnsignedEvent) => { + const signed = await Promise.race([ + signer.signEvent(ev), + new Promise((_r, rej) => + setTimeout(() => rej(new Error('Bunker sign timeout')), 30_000) + ), + ]) + return signed as SignedEvent + }, + } +} +``` + +Notiz: `Nip46Signer.fromBunkerURI` ist der Einstiegspunkt in applesauce-signers 2.x. Bei API-Differenzen (neue Version): `Nip46Signer`-Konstruktor-Signatur via Source-Lookup prüfen. Der Wrapper isoliert die Differenz. + +- [ ] **Step 2: Kein Unit-Test — Integration wird später im `check`-Subcommand getestet.** + +- [ ] **Step 3: Commit** + +```bash +git add publish/src/core/signer.ts +git commit -m "publish(task 10): nip-46 bunker-signer-wrapper mit timeout" +``` + +--- + +## Phase 4 — Bild-Upload (Blossom) + +### Task 11: Bild-Sammler (Post-Ordner → Bild-Dateien) + +**Files:** +- Create: `publish/src/core/image-collector.ts` +- Create: `publish/tests/image-collector_test.ts` + +- [ ] **Step 1: Test schreiben** + +`publish/tests/image-collector_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { collectImages, mimeFromExt } from '../src/core/image-collector.ts' + +Deno.test('mimeFromExt: erkennt gängige formate', () => { + assertEquals(mimeFromExt('a.png'), 'image/png') + assertEquals(mimeFromExt('a.jpg'), 'image/jpeg') + assertEquals(mimeFromExt('a.jpeg'), 'image/jpeg') + assertEquals(mimeFromExt('a.gif'), 'image/gif') + assertEquals(mimeFromExt('a.webp'), 'image/webp') + assertEquals(mimeFromExt('a.svg'), 'image/svg+xml') +}) + +Deno.test('collectImages: liest alle bild-dateien im ordner, ignoriert hugo-derivate', async () => { + const tmp = await Deno.makeTempDir() + try { + await Deno.writeTextFile(`${tmp}/index.md`, '# hi') + await Deno.writeFile(`${tmp}/a.png`, new Uint8Array([1])) + await Deno.writeFile(`${tmp}/b.jpg`, new Uint8Array([2])) + await Deno.writeFile(`${tmp}/a_hu_deadbeef.png`, new Uint8Array([3])) + await Deno.writeTextFile(`${tmp}/notes.txt`, 'ignore me') + const imgs = await collectImages(tmp) + assertEquals(imgs.map((i) => i.fileName).sort(), ['a.png', 'b.jpg']) + assertEquals(imgs.find((i) => i.fileName === 'a.png')?.mimeType, 'image/png') + } finally { + await Deno.remove(tmp, { recursive: true }) + } +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/image-collector_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/core/image-collector.ts` schreiben** + +```typescript +import { extname, join } from '@std/path' + +const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']) + +const MIME_MAP: Record = { + '.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 { + 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 +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/image-collector_test.ts` +Expected: PASS (2 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/image-collector.ts publish/tests/image-collector_test.ts +git commit -m "publish(task 11): image-collector (ignoriert hugo-derivate)" +``` + +--- + +### Task 12: Blossom-Upload-Modul + +**Files:** +- Create: `publish/src/core/blossom.ts` +- Create: `publish/tests/blossom_test.ts` + +- [ ] **Step 1: Test schreiben (mit Injection für HTTP + Signer)** + +`publish/tests/blossom_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { uploadBlob, type BlossomClient } from '../src/core/blossom.ts' + +function fakeSigner() { + return { + getPublicKey: () => Promise.resolve('p'), + signEvent: async (ev: unknown) => ({ + ...(ev as object), + id: 'id', + sig: 'sig', + pubkey: 'p', + }), + } +} + +Deno.test('uploadBlob: pusht zu allen servern, gibt erste url zurück', async () => { + const data = new Uint8Array([1, 2, 3]) + const client: BlossomClient = { + fetch: async (url, _init) => { + return new Response(JSON.stringify({ url: url + '/hash.png', sha256: 'hash' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }, + } + const result = await uploadBlob({ + data, + fileName: 'x.png', + mimeType: 'image/png', + servers: ['https://a.example', 'https://b.example'], + signer: fakeSigner(), + client, + }) + assertEquals(result.ok.length, 2) + assertEquals(result.primaryUrl, 'https://a.example/upload/hash.png') +}) + +Deno.test('uploadBlob: akzeptiert wenn mindestens ein server ok', async () => { + const data = new Uint8Array([1]) + const client: BlossomClient = { + fetch: async (url) => { + if (url.startsWith('https://fail.example')) { + return new Response('nope', { status: 500 }) + } + return new Response(JSON.stringify({ url: url + '/h.png', sha256: 'h' }), { status: 200 }) + }, + } + const result = await uploadBlob({ + data, + fileName: 'x.png', + mimeType: 'image/png', + servers: ['https://fail.example', 'https://ok.example'], + signer: fakeSigner(), + client, + }) + assertEquals(result.ok, ['https://ok.example']) + assertEquals(result.failed, ['https://fail.example']) +}) + +Deno.test('uploadBlob: wirft wenn alle server ablehnen', async () => { + const data = new Uint8Array([1]) + const client: BlossomClient = { + fetch: async () => new Response('err', { status: 500 }), + } + let threw = false + try { + await uploadBlob({ + data, + fileName: 'x.png', + mimeType: 'image/png', + servers: ['https://a.example'], + signer: fakeSigner(), + client, + }) + } catch (err) { + threw = true + assertEquals(String(err).includes('all blossom servers failed'), true) + } + assertEquals(threw, true) +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/blossom_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/core/blossom.ts` schreiben** + +```typescript +import { encodeBase64 } from '@std/encoding/base64' +import type { Signer } from './signer.ts' + +export interface BlossomClient { + fetch(url: string, init: RequestInit): Promise +} + +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 { + const hash = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +async function buildAuth(signer: Signer, hash: string): Promise { + const pubkey = await signer.getPublicKey() + const auth = { + 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, + }) + 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 { + 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 } +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/blossom_test.ts` +Expected: PASS (3 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/blossom.ts publish/tests/blossom_test.ts +git commit -m "publish(task 12): blossom-upload mit multi-server, bud-01 auth" +``` + +--- + +## Phase 5 — Change-Detection und Logging + +### Task 13: Git-Diff-basierte Change-Detection + +**Files:** +- Create: `publish/src/core/change-detection.ts` +- Create: `publish/tests/change-detection_test.ts` + +- [ ] **Step 1: Test schreiben (mit injiziertem Git-Runner)** + +`publish/tests/change-detection_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { changedPostDirs, filterPostDirs, type GitRunner } from '../src/core/change-detection.ts' + +Deno.test('filterPostDirs: extrahiert post-ordner aus dateipfaden (content/posts)', () => { + const lines = [ + 'content/posts/a/index.md', + 'content/posts/b/image.png', + 'content/posts/c/other.md', + 'README.md', + 'app/src/lib/x.ts', + ] + assertEquals( + filterPostDirs(lines, 'content/posts').sort(), + ['content/posts/a', 'content/posts/b'], + ) +}) + +Deno.test('filterPostDirs: respektiert alternativen root (blog/)', () => { + const lines = [ + 'blog/x/index.md', + 'blog/y/pic.png', + 'content/posts/z/index.md', + 'README.md', + ] + assertEquals(filterPostDirs(lines, 'blog').sort(), ['blog/x', 'blog/y']) +}) + +Deno.test('filterPostDirs: ignoriert _drafts und non-index.md', () => { + const lines = [ + 'content/posts/a/index.md', + 'content/posts/a/extra.md', + 'content/posts/_drafts/x/index.md', + ] + assertEquals(filterPostDirs(lines, 'content/posts'), ['content/posts/a']) +}) + +Deno.test('changedPostDirs: nutzt git diff --name-only A..B', async () => { + const runner: GitRunner = async (args) => { + assertEquals(args[0], 'diff') + assertEquals(args[1], '--name-only') + assertEquals(args[2], 'HEAD~1..HEAD') + return 'content/posts/x/index.md\nREADME.md\n' + } + const dirs = await changedPostDirs({ + from: 'HEAD~1', + to: 'HEAD', + contentRoot: 'content/posts', + runner, + }) + assertEquals(dirs, ['content/posts/x']) +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/change-detection_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/core/change-detection.ts` schreiben** + +```typescript +export type GitRunner = (args: string[]) => Promise + +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() + 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 { + 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 { + 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() +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/change-detection_test.ts` +Expected: PASS (4 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/change-detection.ts publish/tests/change-detection_test.ts +git commit -m "publish(task 13): git-diff change-detection für post-ordner" +``` + +--- + +### Task 14: Structured-Logger + +**Files:** +- Create: `publish/src/core/log.ts` +- Create: `publish/tests/log_test.ts` + +- [ ] **Step 1: Test schreiben** + +`publish/tests/log_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { createLogger } from '../src/core/log.ts' + +Deno.test('logger: sammelt post-einträge und schreibt summary', () => { + const sink: string[] = [] + const logger = createLogger({ + mode: 'force-all', + runId: 'run-1', + print: (line) => sink.push(line), + now: () => new Date('2026-04-16T10:00:00Z'), + }) + logger.postSuccess({ + slug: 's1', + action: 'new', + eventId: 'ev1', + relaysOk: ['wss://r1'], + relaysFailed: [], + blossomServersOk: [], + imagesUploaded: 0, + durationMs: 10, + }) + logger.postSkippedDraft('s2') + const summary = logger.finalize(0) + assertEquals(summary.run_id, 'run-1') + assertEquals(summary.mode, 'force-all') + assertEquals(summary.posts.length, 2) + assertEquals(summary.posts[0].status, 'success') + assertEquals(summary.posts[1].status, 'skipped-draft') + assertEquals(summary.exit_code, 0) + assertEquals(sink.some((s) => s.includes('s1')), true) +}) + +Deno.test('logger: writeJson schreibt datei', async () => { + const tmp = await Deno.makeTempDir() + try { + const logger = createLogger({ + mode: 'diff', + runId: 'run-2', + print: () => {}, + now: () => new Date('2026-04-16T10:00:00Z'), + }) + const summary = logger.finalize(0) + await logger.writeJson(`${tmp}/out.json`, summary) + const text = await Deno.readTextFile(`${tmp}/out.json`) + const parsed = JSON.parse(text) + assertEquals(parsed.run_id, 'run-2') + } finally { + await Deno.remove(tmp, { recursive: true }) + } +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/log_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/core/log.ts` schreiben** + +```typescript +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 +} + +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, + } + }, + async writeJson(path, summary) { + await Deno.writeTextFile(path, JSON.stringify(summary, null, 2)) + }, + } +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/log_test.ts` +Expected: PASS (2 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/core/log.ts publish/tests/log_test.ts +git commit -m "publish(task 14): structured json logger" +``` + +--- + +## Phase 6 — Subcommands und CLI + +### Task 15: `processPost`-Pipeline (Kern-Logik) + +**Files:** +- Create: `publish/src/subcommands/publish.ts` +- Create: `publish/tests/publish_test.ts` + +- [ ] **Step 1: Test schreiben** + +`publish/tests/publish_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { processPost, type PostDeps } from '../src/subcommands/publish.ts' +import type { Frontmatter } from '../src/core/frontmatter.ts' + +function makeDeps(overrides: Partial = {}): PostDeps { + return { + readPostFile: async () => ({ + fm: { + title: 'T', + slug: 's', + date: new Date('2024-01-01'), + } as Frontmatter, + body: 'body', + }), + collectImages: async () => [], + uploadBlossom: async (args) => ({ + ok: ['https://b1'], + failed: [], + primaryUrl: `https://b1/${args.fileName}-hash`, + sha256: 'hash', + }), + sign: async (ev) => ({ ...ev, id: 'ev-id', sig: 'sig' }), + publish: async () => ({ ok: ['wss://r1', 'wss://r2'], failed: [] }), + checkExisting: async () => false, + ...overrides, + } +} + +function baseArgs(deps = makeDeps()) { + return { + postDir: '/p/s', + writeRelays: ['wss://r1', 'wss://r2'], + blossomServers: ['https://b1'], + pubkeyHex: 'a'.repeat(64), + clientTag: 'test-client', + minRelayAcks: 2, + deps, + } +} + +Deno.test('processPost: happy-path neu, ohne bilder', async () => { + const result = await processPost(baseArgs()) + assertEquals(result.status, 'success') + assertEquals(result.action, 'new') + assertEquals(result.eventId, 'ev-id') + assertEquals(result.relaysOk.length, 2) +}) + +Deno.test('processPost: draft wird geskippt', async () => { + const deps = makeDeps({ + readPostFile: async () => ({ + fm: { + title: 'T', + slug: 's', + date: new Date('2024-01-01'), + draft: true, + } as Frontmatter, + body: 'b', + }), + }) + const result = await processPost({ ...baseArgs(deps), writeRelays: ['wss://r1'] }) + assertEquals(result.status, 'skipped-draft') +}) + +Deno.test('processPost: zu wenig relay-acks → failed', async () => { + const deps = makeDeps({ + publish: async () => ({ ok: ['wss://r1'], failed: ['wss://r2', 'wss://r3', 'wss://r4'] }), + }) + const result = await processPost({ + ...baseArgs(deps), + writeRelays: ['wss://r1', 'wss://r2', 'wss://r3', 'wss://r4'], + }) + assertEquals(result.status, 'failed') + assertEquals(String(result.error).includes('relays'), true) +}) + +Deno.test('processPost: konfigurierbarer minRelayAcks', async () => { + // 1 Relay-Ack akzeptiert, wenn minRelayAcks=1 + const deps = makeDeps({ + publish: async () => ({ ok: ['wss://r1'], failed: ['wss://r2'] }), + }) + const result = await processPost({ + ...baseArgs(deps), + writeRelays: ['wss://r1', 'wss://r2'], + minRelayAcks: 1, + }) + assertEquals(result.status, 'success') +}) + +Deno.test('processPost: bestehender d-tag → action = update', async () => { + const result = await processPost(baseArgs(makeDeps({ checkExisting: async () => true }))) + assertEquals(result.status, 'success') + assertEquals(result.action, 'update') +}) + +Deno.test('processPost: bilder landen auf blossom, body wird rewritten', async () => { + const uploaded: string[] = [] + const deps = makeDeps({ + readPostFile: async () => ({ + fm: { + title: 'T', + slug: 's', + date: new Date('2024-01-01'), + cover: { image: 'cover.png' }, + } as Frontmatter, + body: 'Pic: ![x](a.png) cover ![c](cover.png)', + }), + collectImages: async () => [ + { + fileName: 'a.png', + absolutePath: '/p/s/a.png', + data: new Uint8Array([1]), + mimeType: 'image/png', + }, + { + fileName: 'cover.png', + absolutePath: '/p/s/cover.png', + data: new Uint8Array([2]), + mimeType: 'image/png', + }, + ], + uploadBlossom: async (args) => { + uploaded.push(args.fileName) + return { + ok: ['https://b1'], + failed: [], + primaryUrl: `https://b1/${args.fileName}-hash`, + sha256: 'h', + } + }, + }) + const result = await processPost(baseArgs(deps)) + assertEquals(result.status, 'success') + assertEquals(uploaded.sort(), ['a.png', 'cover.png']) + assertEquals(result.imagesUploaded, 2) +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/publish_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/subcommands/publish.ts` schreiben** + +```typescript +import { join } from '@std/path' +import { parseFrontmatter, 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 + uploadBlossom(args: { + data: Uint8Array + fileName: string + mimeType: string + }): Promise + sign(ev: UnsignedEvent): Promise + publish(ev: SignedEvent, relays: string[]): Promise + checkExisting(slug: string, relays: string[]): Promise +} + +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 { + 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() + const mapping = new Map() + 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), + } + } +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/publish_test.ts` +Expected: PASS (6 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/subcommands/publish.ts publish/tests/publish_test.ts +git commit -m "publish(task 15): processPost — kern-pipeline pro post (tdd)" +``` + +--- + +### Task 16: `check`-Subcommand (Pre-Flight) + +**Files:** +- Create: `publish/src/subcommands/check.ts` + +- [ ] **Step 1: Modul schreiben** + +`publish/src/subcommands/check.ts`: + +```typescript +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 { + const issues: string[] = [] + + try { + const signer = await createBunkerSigner(config.bunkerUrl) + 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 { + // Health-Check pro Server + 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}`) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add publish/src/subcommands/check.ts +git commit -m "publish(task 16): check-subcommand (pre-flight-validation)" +``` + +--- + +### Task 17: `validate-post`-Subcommand (Offline) + +**Files:** +- Create: `publish/src/subcommands/validate-post.ts` +- Create: `publish/tests/validate-post_test.ts` + +- [ ] **Step 1: Test schreiben** + +`publish/tests/validate-post_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { validatePostFile } from '../src/subcommands/validate-post.ts' + +Deno.test('validatePostFile: ok bei fixture-post', async () => { + const result = await validatePostFile('./tests/fixtures/sample-post.md') + assertEquals(result.ok, true) + assertEquals(result.slug, 'sample-slug') +}) + +Deno.test('validatePostFile: fehler bei fehlender datei', async () => { + const result = await validatePostFile('./does-not-exist.md') + assertEquals(result.ok, false) + assertEquals(result.error?.includes('read'), true) +}) + +Deno.test('validatePostFile: fehler bei ungültigem slug', async () => { + const tmp = await Deno.makeTempFile({ suffix: '.md' }) + try { + await Deno.writeTextFile( + tmp, + '---\ntitle: "T"\nslug: "Bad Slug"\ndate: 2024-01-01\n---\n\nbody', + ) + const result = await validatePostFile(tmp) + assertEquals(result.ok, false) + assertEquals(result.error?.includes('slug'), true) + } finally { + await Deno.remove(tmp) + } +}) +``` + +- [ ] **Step 2: Verifiziere FAIL** + +Run: `cd publish && deno test tests/validate-post_test.ts` +Expected: FAIL + +- [ ] **Step 3: `publish/src/subcommands/validate-post.ts` schreiben** + +```typescript +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 { + 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) } + } +} +``` + +- [ ] **Step 4: Tests PASS** + +Run: `cd publish && deno test tests/validate-post_test.ts` +Expected: PASS (3 Tests) + +- [ ] **Step 5: Commit** + +```bash +git add publish/src/subcommands/validate-post.ts publish/tests/validate-post_test.ts +git commit -m "publish(task 17): validate-post-subcommand" +``` + +--- + +### Task 18: CLI-Entrypoint mit Subcommand-Dispatcher + +**Files:** +- Create: `publish/src/cli.ts` + +- [ ] **Step 1: Modul schreiben** + +`publish/src/cli.ts`: + +```typescript +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 { processPost, type PostDeps } 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 { + const config = loadConfig() + const result = await runCheck(config) + printCheckResult(result) + return result.ok ? 0 : 1 +} + +async function cmdValidatePost(path: string | undefined): Promise { + if (!path) { + console.error('usage: validate-post ') + 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 { + 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 { + 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 { + const config = loadConfig() + const mode: RunMode = flags.post ? 'post-single' : flags.forceAll ? 'force-all' : 'diff' + const runId = uuid() + const logger = createLogger({ mode, runId }) + + const signer = await createBunkerSigner(config.bunkerUrl) + const outbox = await loadOutbox(config.bootstrapRelay, config.authorPubkeyHex) + const blossomServers = await loadBlossomServers(config.bootstrapRelay, config.authorPubkeyHex) + 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 { + 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 [flags]') + return 2 +} + +if (import.meta.main) { + Deno.exit(await main()) +} +``` + +- [ ] **Step 2: Smoke-Test** + +Run: `cd publish && deno run src/cli.ts` +Expected: Usage-Message, Exit-Code 2. + +Run: `cd publish && deno run --allow-read src/cli.ts validate-post tests/fixtures/sample-post.md` +Expected: `✓ tests/fixtures/sample-post.md ok (slug: sample-slug)` + +- [ ] **Step 3: Commit** + +```bash +git add publish/src/cli.ts +git commit -m "publish(task 18): cli-entrypoint mit subcommand-dispatch" +``` + +--- + +## Phase 7 — Pre-Flight gegen reale Infrastruktur + +### Task 19: `deno task check` gegen Amber + Relays + Blossom + +**Files:** keine Änderungen — nur Verifikation. + +- [ ] **Step 1: `deno task check` laufen lassen** + +Run: `cd publish && deno task check` + +Erwartung: `✓ pre-flight ok`. Bei Fehlern: +- **Bunker-Ping-Timeout:** Amber öffnen, Akku-Optimierung deaktivieren, Permission für Pipeline-App auf auto-approve für `kind:30023` und `kind:24242` setzen. +- **kind:10002 fehlt / leer:** siehe Spec §2.3 — Event manuell publizieren. +- **kind:10063 fehlt / leer:** siehe Spec §2.4 — Event manuell publizieren. +- **Blossom-Server 4xx/5xx:** anderen Server in `kind:10063` eintragen. + +- [ ] **Step 2: Kein Commit. Nur Verifikation.** + +--- + +## Phase 8 — Integrationstest: Einzel-Post + +### Task 20: Dry-run + echte Publikation eines einzelnen Posts + +**Files:** keine Änderungen. + +- [ ] **Step 1: Dry-run** + +Run: +```bash +cd publish && deno task publish --post offenheit-das-wesentliche --dry-run +``` + +Expected: `mode=post-single posts=1 runId=` + `dry-run: content/posts/2024-01-16-offenheit-das-wesentliche`. + +- [ ] **Step 2: Echte Einzel-Publikation** + +```bash +cd publish && deno task publish --post offenheit-das-wesentliche +``` + +Beobachten: +- Amber zeigt N Signatur-Requests: 1 × `kind:30023` (Event) + M × `kind:24242` (Blossom-Auth, pro Bild). +- Auto-approve sollte alle ohne manuellen Tap durchwinken. +- Log: `images_uploaded: M`, `relays_ok.length ≥ 2`. + +Expected-Exit-Code: 0, Log in `publish/logs/publish-*.json`. + +- [ ] **Step 3: Event auf Relay verifizieren** + +```bash +nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 --tag d=offenheit-das-wesentliche wss://relay.damus.io 2>/dev/null | jq -c '{id, kind, tags: (.tags[:5])}' +``` + +Expected: genau 1 Event mit `d`, `title`, `published_at`, `summary`, `image`-Tags. + +- [ ] **Step 4: Bild auf Blossom verifizieren** + +URL aus dem Event-Content (`content`) herausziehen, per `curl -sI` prüfen. Erwartung: HTTP 200. + +- [ ] **Step 5: Live-Check auf der SPA** + +Öffne `https://svelte.joerg-lohrer.de/`, der Post sollte in der Liste erscheinen. Bilder laden von Blossom, Layout okay? + +**Wenn Probleme auftreten, HIER STOPPEN** und mit dem User debuggen, bevor `--force-all` läuft. + +--- + +## Phase 9 — Massen-Migration + +### Task 21: Alle 18 Posts publizieren + +**Files:** keine Code-Änderung. + +- [ ] **Step 1: Event-Stand vor der Migration sichern** + +```bash +nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.damus.io 2>/dev/null | jq -s 'length' +``` + +Zahl notieren (sollte ~10 sein, siehe STATUS.md). + +- [ ] **Step 2: Dry-run auf alle** + +```bash +cd publish && deno task publish --force-all --dry-run +``` + +Expected: `mode=force-all posts=18`. + +- [ ] **Step 3: Echte Migration** + +```bash +cd publish && deno task publish --force-all +``` + +Beobachten: +- Amber online, Akku-Optimierung aus, Auto-Approve aktiv. +- Pipeline läuft sequenziell. +- 18 `kind:30023`-Signaturen + N × `kind:24242` (pro Bild eines). +- Erwartet: ~3–5 min Gesamtlaufzeit bei ~90 Bildern. + +Expected: Exit-Code 0, Log mit 18 Einträgen, alle `status: success`. + +- [ ] **Step 4: Verifikation auf Relay** + +```bash +nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.damus.io 2>/dev/null | jq -s 'length' +``` + +Expected: `18`. + +- [ ] **Step 5: SPA-Stichprobe** + +Mindestens 5 Posts auf `https://svelte.joerg-lohrer.de/` durchklicken. Bilder laden? Kommentare erreichbar? Layout korrekt? + +- [ ] **Step 6: Log archivieren** + +```bash +mkdir -p docs/publish-logs +cp publish/logs/publish-*.json docs/publish-logs/2026-04-16-force-all-migration.json +git add docs/publish-logs/2026-04-16-force-all-migration.json +git commit -m "docs: publish-pipeline force-all migration log" +``` + +--- + +## Phase 10 — GitHub-Actions-Workflow + +### Task 22: CI-Workflow + +**Files:** +- Create: `.github/workflows/publish.yml` + +- [ ] **Step 1: Workflow schreiben** + +`.github/workflows/publish.yml`: + +```yaml +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 }} + 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 }} + 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 +``` + +- [ ] **Step 2: GitHub-Actions-Secrets anlegen (manueller Schritt)** + +Settings → Secrets and variables → Actions → New repository secret: +- `BUNKER_URL` +- `AUTHOR_PUBKEY_HEX` = `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41` +- `BOOTSTRAP_RELAY` = `wss://relay.primal.net` + +- [ ] **Step 3: Alle Tests laufen** + +Run: `cd publish && deno task test` +Expected: alle PASS. + +- [ ] **Step 4: Commit und Push** + +```bash +git add .github/workflows/publish.yml +git commit -m "publish(task 22): github-actions-workflow für auto-publish" +git push origin spa +``` + +- [ ] **Step 5: Workflow manuell triggern (ohne force)** + +GitHub-UI → Actions → „Publish Nostr Events" → „Run workflow" → Branch `spa`. Erwartung: Check läuft grün, keine Content-Änderung → 0 Posts, Exit-Code 0. + +- [ ] **Step 6: End-to-End-Test mit Content-Commit** + +Minimalen Edit in einem Post machen, pushen. Workflow sollte automatisch triggern, Post re-publishen. Log-Artefakt prüfen. + +--- + +## Phase 11 — Abschluss + +### Task 23: Dokumentation aktualisieren + +**Files:** +- Modify: `docs/STATUS.md` +- Modify: `docs/HANDOFF.md` + +- [ ] **Step 1: STATUS.md aktualisieren** + +- §2 „Was auf Nostr liegt": Event-Zahl auf 18 aktualisieren, Blossom-Erläuterung („alle Bilder auf Blossom"). +- §6 „Offene Punkte": Publish-Pipeline als erledigt markieren. Menü-Nav + Impressum + Cutover bleiben offen. + +- [ ] **Step 2: HANDOFF.md aktualisieren** + +- „Option 1 — Publish-Pipeline" → Status: erledigt. +- Neues „Was als Nächstes": + - Option 2 (Menü-Navigation + Impressum) + - Option 3 (Cutover: Hauptdomain `joerg-lohrer.de` auf SvelteKit umstellen — Voraussetzung Publish-Pipeline live; jetzt möglich). + +- [ ] **Step 3: Commit** + +```bash +git add docs/STATUS.md docs/HANDOFF.md +git commit -m "docs: publish-pipeline als erledigt markiert, cutover freigegeben" +``` + +--- + +### Task 24: Merge nach `main` + +**Files:** keine. + +- [ ] **Step 1: Alle Tests** + +Run: `cd publish && deno task test` +Run: `cd app && npm run check && npm run test:unit && npm run test:e2e` +Expected: alle PASS. + +- [ ] **Step 2: Push** + +```bash +git push origin spa +``` + +- [ ] **Step 3: Mit User besprechen, ob `spa` → `main` gemergt wird** + +Kein automatischer Merge. Entscheidung beim User. Ende. + +--- + +## Gesamte Verifikation + +- [ ] `cd publish && deno task test` → alle PASS. +- [ ] `cd publish && deno task check` → `✓ pre-flight ok`. +- [ ] `curl -sI https://svelte.joerg-lohrer.de/` → 200. +- [ ] `nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.damus.io 2>/dev/null | jq -s 'length'` → 18 oder mehr. +- [ ] GitHub Actions Workflow grün. + +--- + +## Anhang — Modul-Referenz + +| Modul | Verantwortung | Tests | +|---|---|---| +| `src/core/config.ts` | Env-Variable laden, validieren | `tests/config_test.ts` | +| `src/core/frontmatter.ts` | YAML-Frontmatter-Parsing, Body-Split | `tests/frontmatter_test.ts` | +| `src/core/validation.ts` | Slug-Regex, Post-Pflichtfelder | `tests/validation_test.ts` | +| `src/core/markdown.ts` | Bild-URL-Rewrite (mapping-basiert) | `tests/markdown_test.ts` | +| `src/core/event.ts` | `buildKind30023` | `tests/event_test.ts` | +| `src/core/relays.ts` | publish zu Relays, checkExisting | `tests/relays_test.ts` | +| `src/core/outbox.ts` | `kind:10002` Parser + Loader | `tests/outbox_test.ts` | +| `src/core/blossom-list.ts` | `kind:10063` Parser + Loader | `tests/blossom-list_test.ts` | +| `src/core/blossom.ts` | BUD-01 PUT /upload, Auth-Signing | `tests/blossom_test.ts` | +| `src/core/image-collector.ts` | Post-Ordner scannen (ignoriert Hugo-Derivate) | `tests/image-collector_test.ts` | +| `src/core/change-detection.ts` | Git-Diff, allPostDirs | `tests/change-detection_test.ts` | +| `src/core/log.ts` | Strukturiertes JSON-Log | `tests/log_test.ts` | +| `src/core/signer.ts` | NIP-46-Bunker-Wrapper | (integrated in check) | +| `src/subcommands/publish.ts` | `processPost`-Pipeline | `tests/publish_test.ts` | +| `src/subcommands/check.ts` | Pre-Flight-Aggregation | (integrated) | +| `src/subcommands/validate-post.ts` | Offline-Frontmatter-Check | `tests/validate-post_test.ts` | +| `src/cli.ts` | CLI-Entrypoint + Dispatch | (smoke-tested) | diff --git a/docs/superpowers/specs/2026-04-15-publish-pipeline-design.md b/docs/superpowers/specs/2026-04-15-publish-pipeline-design.md index 435d747..d7dce0a 100644 --- a/docs/superpowers/specs/2026-04-15-publish-pipeline-design.md +++ b/docs/superpowers/specs/2026-04-15-publish-pipeline-design.md @@ -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: `/` oder `/.`). +4. Pipeline baut eine Mapping-Tabelle ``. +5. Markdown-Body wird traversiert, alle erkannten Bild-Patterns werden ersetzt: + - `![alt](filename.png)` → `![alt]()` + - `[![alt](filename.png)](link)` → `[![alt]()](link)` + - `![alt](filename.png =WxH)` → `![alt]()` (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///
/.html/` - - `YYYY/MM/DD` aus `date:`-Frontmatter, nicht aus dem Signatur-Zeitpunkt. - - `` 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" \ - /*.{png,jpg,jpeg,gif,webp,svg} \ - $ALLINKL_DEPLOY_ROOT//
/.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 `` 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//`. +- 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/.html/` — 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/.html/` 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. diff --git a/docs/superpowers/specs/2026-04-16-image-metadata-convention.md b/docs/superpowers/specs/2026-04-16-image-metadata-convention.md new file mode 100644 index 0000000..fdea384 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-image-metadata-convention.md @@ -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 ", + "m ", + "x ", + "alt ", // nur wenn nicht leer + "caption ", // nur wenn vorhanden + "license ", // nur wenn konkrete URL (nicht UNKNOWN) + "author ", // eins pro Autor, nur wenn konkret (nicht UNKNOWN) + "source_url ", // nur wenn vorhanden + "modifications " // 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", ""]`. 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 diff --git a/docs/wiki-draft-nostr-image-metadata.md b/docs/wiki-draft-nostr-image-metadata.md new file mode 100644 index 0000000..2ba9f63 --- /dev/null +++ b/docs/wiki-draft-nostr-image-metadata.md @@ -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 `
` blocks** with `
`. 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", ""]` +- `["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) diff --git a/docs/wiki-entwurf-nostr-bild-metadaten.md b/docs/wiki-entwurf-nostr-bild-metadaten.md new file mode 100644 index 0000000..d0a6dd2 --- /dev/null +++ b/docs/wiki-entwurf-nostr-bild-metadaten.md @@ -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)