publish(task 15): processPost — kern-pipeline pro post (tdd)

processPost(args) orchestriert pro post:
  1. frontmatter parsen + validieren
  2. draft → skipped-draft
  3. bilder sammeln + sequentiell zu blossom hochladen (mapping
     dateiname → primary-url)
  4. body mit rewriteImageUrls anpassen, coverUrl via resolveCoverUrl
  5. kind:30023 event bauen via buildKind30023
  6. checkExisting → action = new|update
  7. signieren via nip-46
  8. publishToRelays, prüfen ob minRelayAcks erreicht

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-18 05:37:05 +02:00
parent db85061287
commit 68ea912fad
2 changed files with 269 additions and 0 deletions

View File

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

View File

@ -0,0 +1,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> = {}): 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)
})