From 68ea912fade3ba5a6ba4ec1e9a53a2bfe3f776e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Sat, 18 Apr 2026 05:37:05 +0200 Subject: [PATCH] =?UTF-8?q?publish(task=2015):=20processPost=20=E2=80=94?= =?UTF-8?q?=20kern-pipeline=20pro=20post=20(tdd)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processPost(args) orchestriert pro post: 1. frontmatter parsen + validieren 2. draft → skipped-draft 3. bilder sammeln + sequentiell zu blossom hochladen (mapping dateiname → primary-url) 4. body mit rewriteImageUrls anpassen, coverUrl via resolveCoverUrl 5. kind:30023 event bauen via buildKind30023 6. checkExisting → action = new|update 7. signieren via nip-46 8. publishToRelays, prüfen ob minRelayAcks erreicht alle externen abhängigkeiten (readPostFile, collectImages, upload, sign, publish, checkExisting) via PostDeps-interface eingezogen — einfach mockbar. fehler aller art landen als { status: failed, error: msg }. 6 tests grün. follow-up (nicht teil von task 15): license-tag und imeta-tags aus images[]-frontmatter sind noch nicht im event. kommt in eigenem folge-task, basierend auf der metadata-convention-spec. Co-Authored-By: Claude Opus 4.6 (1M context) --- publish/src/subcommands/publish.ts | 126 +++++++++++++++++++++++++ publish/tests/publish_test.ts | 143 +++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 publish/src/subcommands/publish.ts create mode 100644 publish/tests/publish_test.ts diff --git a/publish/src/subcommands/publish.ts b/publish/src/subcommands/publish.ts new file mode 100644 index 0000000..369efa3 --- /dev/null +++ b/publish/src/subcommands/publish.ts @@ -0,0 +1,126 @@ +import { join } from '@std/path' +import { type Frontmatter } from '../core/frontmatter.ts' +import { validatePost } from '../core/validation.ts' +import { buildKind30023, type UnsignedEvent } from '../core/event.ts' +import { resolveCoverUrl, rewriteImageUrls } from '../core/markdown.ts' +import type { ImageFile } from '../core/image-collector.ts' +import type { RelaysReport, SignedEvent } from '../core/relays.ts' +import type { UploadReport } from '../core/blossom.ts' + +export interface PostDeps { + readPostFile(path: string): Promise<{ fm: Frontmatter; body: string }> + collectImages(postDir: string): Promise + 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), + } + } +} diff --git a/publish/tests/publish_test.ts b/publish/tests/publish_test.ts new file mode 100644 index 0000000..f395212 --- /dev/null +++ b/publish/tests/publish_test.ts @@ -0,0 +1,143 @@ +import { assertEquals } from '@std/assert' +import { type PostDeps, processPost } from '../src/subcommands/publish.ts' +import type { Frontmatter } from '../src/core/frontmatter.ts' + +function makeDeps(overrides: Partial = {}): PostDeps { + return { + readPostFile: () => + Promise.resolve({ + fm: { + title: 'T', + slug: 's', + date: new Date('2024-01-01'), + } as Frontmatter, + body: 'body', + }), + collectImages: () => Promise.resolve([]), + uploadBlossom: (args) => + Promise.resolve({ + ok: ['https://b1'], + failed: [], + primaryUrl: `https://b1/${args.fileName}-hash`, + sha256: 'hash', + }), + sign: (ev) => Promise.resolve({ ...ev, id: 'ev-id', sig: 'sig' }), + publish: () => Promise.resolve({ ok: ['wss://r1', 'wss://r2'], failed: [] }), + checkExisting: () => Promise.resolve(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: () => + Promise.resolve({ + 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: () => + Promise.resolve({ 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 () => { + const deps = makeDeps({ + publish: () => Promise.resolve({ 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: () => Promise.resolve(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: () => + Promise.resolve({ + 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: () => + Promise.resolve([ + { + 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: (args) => { + uploaded.push(args.fileName) + return Promise.resolve({ + 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) +})