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