joerglohrerde/docs/superpowers/plans/2026-04-16-publish-pipeline.md

2910 lines
82 KiB
Markdown
Raw Normal View History

# 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://<hex>?relay=wss://...&secret=...
# Autor-Pubkey als 64 Zeichen lowercase hex (entspricht dem Bunker-Account)
AUTHOR_PUBKEY_HEX=
# Bootstrap-Relay zum Laden von kind:10002 und kind:10063
BOOTSTRAP_RELAY=wss://relay.damus.io
# ==== OPTIONAL ====
# Wurzel der Markdown-Posts, relativ zu diesem publish/-Ordner.
# Default: ../content/posts
CONTENT_ROOT=../content/posts
# Wird als ["client", "<wert>"]-Tag in jedes kind:30023-Event eingetragen.
# Hilft bei der Zuordnung der Event-Herkunft. Default: 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 <slug>` — nur ein Post.
- `deno task publish --dry-run` — zeigt, was publiziert würde, ohne Uploads.
- `deno task validate-post content/posts/<ordner>/index.md` — Frontmatter-Check.
- `deno task test` — Tests.
## Struktur
- `src/core/` — Library (Frontmatter, Markdown, Events, Signer, Relays, Blossom).
- `src/subcommands/` — CLI-Befehle.
- `src/cli.ts` — Entrypoint, Subcommand-Dispatcher.
- `tests/` — Unit- und Integration-Tests.
- `.github/workflows/publish.yml` — CI-Workflow.
```
- [ ] **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<string, string> = {}
for (const key of REQUIRED) {
const v = read(key)
if (!v) missing.push(key)
else values[key] = v
}
if (missing.length) {
throw new Error(`Missing env: ${missing.join(', ')}`)
}
if (!/^[0-9a-f]{64}$/.test(values.AUTHOR_PUBKEY_HEX)) {
throw new Error('AUTHOR_PUBKEY_HEX must be 64 lowercase hex characters')
}
const minAcksRaw = read('MIN_RELAY_ACKS') ?? DEFAULTS.MIN_RELAY_ACKS
const minAcks = Number(minAcksRaw)
if (!Number.isInteger(minAcks) || minAcks < 1) {
throw new Error(`MIN_RELAY_ACKS must be a positive integer, got "${minAcksRaw}"`)
}
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, string>): string {
return md.replace(IMG_RE, (full, alt: string, url: string) => {
if (isAbsolute(url)) return full.replace(/\s+=\d+x\d+\)$/, ')')
let decoded: string
try {
decoded = decodeURIComponent(url)
} catch {
decoded = url
}
const target = mapping.get(decoded) ?? mapping.get(url)
if (!target) return full.replace(/\s+=\d+x\d+\)$/, ')')
return `![${alt}](${target})`
})
}
export function resolveCoverUrl(
coverRaw: string | undefined,
mapping: Map<string, string>,
): string | undefined {
if (!coverRaw) return undefined
if (isAbsolute(coverRaw)) return coverRaw
return mapping.get(coverRaw)
}
```
- [ ] **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<PublishResult>
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<PublishOptions>,
): Promise<boolean> {
const total = opts.retries + 1
for (let i = 0; i < total; i++) {
const attempt = Promise.race([
opts.publishFn(url, ev),
new Promise<PublishResult>((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<RelaysReport> {
const opts: Required<PublishOptions> = {
publishFn: options.publishFn ?? defaultPublish,
retries: options.retries ?? 2,
timeoutMs: options.timeoutMs ?? 10_000,
backoffMs: options.backoffMs ?? 1000,
}
const results = await Promise.all(
urls.map(async (url) => ({ url, ok: await publishOne(url, ev, opts) })),
)
return {
ok: results.filter((r) => r.ok).map((r) => r.url),
failed: results.filter((r) => !r.ok).map((r) => r.url),
}
}
export type ExistingQuery = (url: string, pubkey: string, slug: string) => Promise<boolean>
const defaultExistingQuery: ExistingQuery = async (url, pubkey, slug) => {
try {
const relay = new Relay(url)
const ev = await firstValueFrom(
relay
.request({ kinds: [30023], authors: [pubkey], '#d': [slug], limit: 1 })
.pipe(timeout({ first: 5_000 })),
)
return !!ev
} catch {
return false
}
}
export async function checkExisting(
slug: string,
pubkey: string,
urls: string[],
opts: { query?: ExistingQuery } = {},
): Promise<boolean> {
const query = opts.query ?? defaultExistingQuery
const results = await Promise.all(urls.map((u) => query(u, pubkey, slug)))
return results.some((r) => r)
}
```
- [ ] **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<Outbox> {
const relay = new Relay(bootstrapRelay)
const ev = await firstValueFrom(
relay
.request({ kinds: [10002], authors: [authorPubkeyHex], limit: 1 })
.pipe(timeout({ first: 10_000 })),
) as SignedEvent
return parseOutbox(ev)
}
```
- [ ] **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<string[]> {
const relay = new Relay(bootstrapRelay)
const ev = await firstValueFrom(
relay
.request({ kinds: [10063], authors: [authorPubkeyHex], limit: 1 })
.pipe(timeout({ first: 10_000 })),
) as SignedEvent
return parseBlossomServers(ev)
}
```
- [ ] **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<string>
signEvent(ev: UnsignedEvent): Promise<SignedEvent>
}
export async function createBunkerSigner(bunkerUrl: string): Promise<Signer> {
const signer = Nip46Signer.fromBunkerURI(bunkerUrl)
const pubkey = await Promise.race([
signer.getPublicKey(),
new Promise<never>((_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<never>((_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<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
}
const HUGO_DERIVATIVE = /_hu_[0-9a-f]+\./
export function mimeFromExt(filename: string): string {
return MIME_MAP[extname(filename).toLowerCase()] ?? 'application/octet-stream'
}
export interface ImageFile {
fileName: string
absolutePath: string
data: Uint8Array
mimeType: string
}
export async function collectImages(postDir: string): Promise<ImageFile[]> {
const results: ImageFile[] = []
for await (const entry of Deno.readDir(postDir)) {
if (!entry.isFile) continue
if (HUGO_DERIVATIVE.test(entry.name)) continue
const ext = extname(entry.name).toLowerCase()
if (!IMG_EXTS.has(ext)) continue
const abs = join(postDir, entry.name)
const data = await Deno.readFile(abs)
results.push({
fileName: entry.name,
absolutePath: abs,
data,
mimeType: mimeFromExt(entry.name),
})
}
results.sort((a, b) => a.fileName.localeCompare(b.fileName))
return results
}
```
- [ ] **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<Response>
}
export interface UploadArgs {
data: Uint8Array
fileName: string
mimeType: string
servers: string[]
signer: Signer
client?: BlossomClient
}
export interface UploadReport {
ok: string[]
failed: string[]
primaryUrl: string
sha256: string
}
async function sha256Hex(data: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
async function buildAuth(signer: Signer, hash: string): Promise<string> {
const pubkey = await signer.getPublicKey()
const auth = {
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<UploadReport> {
const client = args.client ?? defaultClient
const hash = await sha256Hex(args.data)
const auth = await buildAuth(args.signer, hash)
const results = await Promise.all(
args.servers.map((s) =>
uploadOne(s, args.data, args.mimeType, auth, client).then((r) => ({ server: s, ...r }))
),
)
const ok = results.filter((r) => r.ok).map((r) => r.server)
const failed = results.filter((r) => !r.ok).map((r) => r.server)
if (ok.length === 0) {
throw new Error(`all blossom servers failed for ${args.fileName}`)
}
const first = results.find((r) => r.ok && r.url)!
return { ok, failed, primaryUrl: first.url!, sha256: hash }
}
```
- [ ] **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<string>
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function filterPostDirs(lines: string[], contentRoot: string): string[] {
const root = contentRoot.replace(/\/$/, '')
const prefix = root + '/'
const indexRe = new RegExp(`^${escapeRegex(prefix)}([^/]+)/index\\.md$`)
const assetRe = new RegExp(`^${escapeRegex(prefix)}([^/]+)/`)
const drafts = prefix + '_'
const dirs = new Set<string>()
for (const line of lines) {
const l = line.trim()
if (!l) continue
if (l.startsWith(drafts)) continue
const indexMatch = l.match(indexRe)
if (indexMatch) {
dirs.add(`${prefix}${indexMatch[1]}`)
continue
}
const assetMatch = l.match(assetRe)
if (assetMatch && !l.endsWith('.md')) {
dirs.add(`${prefix}${assetMatch[1]}`)
}
}
return [...dirs].sort()
}
const defaultRunner: GitRunner = async (args) => {
const proc = new Deno.Command('git', { args, stdout: 'piped', stderr: 'piped' })
const out = await proc.output()
if (out.code !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${new TextDecoder().decode(out.stderr)}`)
}
return new TextDecoder().decode(out.stdout)
}
export interface DiffArgs {
from: string
to: string
contentRoot: string
runner?: GitRunner
}
export async function changedPostDirs(args: DiffArgs): Promise<string[]> {
const runner = args.runner ?? defaultRunner
const stdout = await runner(['diff', '--name-only', `${args.from}..${args.to}`])
return filterPostDirs(stdout.split('\n'), args.contentRoot)
}
export async function allPostDirs(contentRoot: string): Promise<string[]> {
const result: string[] = []
for await (const entry of Deno.readDir(contentRoot)) {
if (entry.isDirectory && !entry.name.startsWith('_')) {
const indexPath = `${contentRoot}/${entry.name}/index.md`
try {
const stat = await Deno.stat(indexPath)
if (stat.isFile) result.push(`${contentRoot}/${entry.name}`)
} catch {
// skip folders without index.md
}
}
}
return result.sort()
}
```
- [ ] **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<void>
}
export function createLogger(opts: LoggerOptions): Logger {
const print = opts.print ?? ((line: string) => console.log(line))
const now = opts.now ?? (() => new Date())
const posts: PostLog[] = []
const startedAt = now().toISOString()
return {
postSuccess(a) {
posts.push({
slug: a.slug,
status: 'success',
action: a.action,
event_id: a.eventId,
relays_ok: a.relaysOk,
relays_failed: a.relaysFailed,
blossom_servers_ok: a.blossomServersOk,
images_uploaded: a.imagesUploaded,
duration_ms: a.durationMs,
})
print(
`✓ ${a.slug} (${a.action}) — relays:${a.relaysOk.length}ok/${a.relaysFailed.length}fail — ${a.durationMs}ms`,
)
},
postFailed(a) {
posts.push({
slug: a.slug,
status: 'failed',
error: a.error,
duration_ms: a.durationMs,
})
print(`✗ ${a.slug} — ${a.error}`)
},
postSkippedDraft(slug) {
posts.push({ slug, status: 'skipped-draft' })
print(`- ${slug} (draft, skipped)`)
},
finalize(exitCode) {
return {
run_id: opts.runId,
started_at: startedAt,
ended_at: now().toISOString(),
mode: opts.mode,
posts,
exit_code: exitCode,
}
},
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> = {}): 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<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),
}
}
}
```
- [ ] **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<CheckResult> {
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<ValidateResult> {
let text: string
try {
text = await Deno.readTextFile(path)
} catch (err) {
return { ok: false, error: `cannot read ${path}: ${err instanceof Error ? err.message : err}` }
}
try {
const { fm } = parseFrontmatter(text)
validatePost(fm)
return { ok: true, slug: fm.slug }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
}
```
- [ ] **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<number> {
const config = loadConfig()
const result = await runCheck(config)
printCheckResult(result)
return result.ok ? 0 : 1
}
async function cmdValidatePost(path: string | undefined): Promise<number> {
if (!path) {
console.error('usage: validate-post <path-to-index.md>')
return 2
}
const result = await validatePostFile(path)
if (result.ok) {
console.log(`✓ ${path} ok (slug: ${result.slug})`)
return 0
}
console.error(`✗ ${path}: ${result.error}`)
return 1
}
async function findBySlug(dirs: string[], slug: string): Promise<string | undefined> {
for (const d of dirs) {
try {
const text = await Deno.readTextFile(join(d, 'index.md'))
const { fm } = parseFrontmatter(text)
if (fm.slug === slug) return d
} catch {
// skip
}
}
return undefined
}
async function resolvePostDirs(
mode: RunMode,
contentRoot: string,
single?: string,
): Promise<string[]> {
if (mode === 'post-single' && single) {
if (single.startsWith(contentRoot + '/')) return [single]
const all = await allPostDirs(contentRoot)
const match = all.find((d) => d.endsWith(`/${single}`)) ?? (await findBySlug(all, single))
if (!match) throw new Error(`post mit slug "${single}" nicht gefunden`)
return [match]
}
if (mode === 'force-all') return await allPostDirs(contentRoot)
const before = Deno.env.get('GITHUB_EVENT_BEFORE') ?? 'HEAD~1'
return await changedPostDirs({ from: before, to: 'HEAD', contentRoot })
}
async function cmdPublish(flags: {
forceAll: boolean
post?: string
dryRun: boolean
}): Promise<number> {
const config = loadConfig()
const mode: RunMode = flags.post ? 'post-single' : flags.forceAll ? 'force-all' : 'diff'
const runId = uuid()
const logger = createLogger({ mode, runId })
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<number> {
const args = parseArgs(Deno.args, {
boolean: ['force-all', 'dry-run'],
string: ['post'],
})
const sub = args._[0]
if (sub === 'check') return cmdCheck()
if (sub === 'validate-post') return cmdValidatePost(args._[1] as string | undefined)
if (sub === 'publish') {
return cmdPublish({
forceAll: args['force-all'] === true,
post: args.post,
dryRun: args['dry-run'] === true,
})
}
console.error('usage: cli.ts <publish | check | validate-post> [flags]')
return 2
}
if (import.meta.main) {
Deno.exit(await main())
}
```
- [ ] **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=<uuid>` + `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: ~35 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) |