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

2910 lines
82 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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