82 KiB
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):
publish/.env— lokale Publish-Config (gitignored, Template:publish/.env.example).- Fallback:
../.env.localim Repo-Root, falls vorhanden (für Repos, die schon eine.env.localpflegen). - 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.jsoncschreiben
publish/deno.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/.gitignoreschreiben
.env
logs/
- Step 3:
publish/.env.exampleschreiben (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.mdschreiben
# 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)
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:
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.tsschreiben
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
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.mdanlegen
---
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.

- Step 2: Test schreiben
publish/tests/frontmatter_test.ts:
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.tsschreiben
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
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:
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.tsschreiben
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
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:
import { assertEquals } from '@std/assert'
import { rewriteImageUrls } from '../src/core/markdown.ts'
Deno.test('rewriteImageUrls: ersetzt  durch Mapping', () => {
const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']])
const input = ''
assertEquals(rewriteImageUrls(input, mapping), '')
})
Deno.test('rewriteImageUrls: absolute URL bleibt unverändert', () => {
const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']])
const input = ''
assertEquals(rewriteImageUrls(input, mapping), input)
})
Deno.test('rewriteImageUrls: entfernt =WxH-Suffix', () => {
const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']])
const input = ''
assertEquals(rewriteImageUrls(input, mapping), '')
})
Deno.test('rewriteImageUrls: bild-in-link [](link)', () => {
const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']])
const input = '[](https://target.example.com)'
assertEquals(
rewriteImageUrls(input, mapping),
'[](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  more  end'
assertEquals(
rewriteImageUrls(input, mapping),
'Text  more  end',
)
})
Deno.test('rewriteImageUrls: lässt unbekannte Dateinamen stehen', () => {
const mapping = new Map([['cat.png', 'https://bl/c.png']])
const input = ''
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 = ''
assertEquals(rewriteImageUrls(input, mapping), '')
})
- Step 2: Verifiziere FAIL
Run: cd publish && deno test tests/markdown_test.ts
Expected: FAIL
- Step 3:
publish/src/core/markdown.tsschreiben
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 ``
})
}
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
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:
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.tsschreiben
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
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:
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.tsschreiben
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
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:
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.tsschreiben
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
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:
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.tsschreiben
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
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:
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
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:
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.tsschreiben
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
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:
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.tsschreiben
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
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:
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.tsschreiben
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
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:
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.tsschreiben
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
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:
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:  cover ',
}),
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.tsschreiben
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
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:
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
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:
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.tsschreiben
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
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:
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
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 checklaufen 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:30023undkind:24242setzen. -
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:10063eintragen. -
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:
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
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
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
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
cd publish && deno task publish --force-all --dry-run
Expected: mode=force-all posts=18.
- Step 3: Echte Migration
cd publish && deno task publish --force-all
Beobachten:
- Amber online, Akku-Optimierung aus, Auto-Approve aktiv.
- Pipeline läuft sequenziell.
- 18
kind:30023-Signaturen + N ×kind:24242(pro Bild eines). - Erwartet: ~3–5 min Gesamtlaufzeit bei ~90 Bildern.
Expected: Exit-Code 0, Log mit 18 Einträgen, alle status: success.
- Step 4: Verifikation auf Relay
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
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:
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
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.deauf SvelteKit umstellen — Voraussetzung Publish-Pipeline live; jetzt möglich).
-
Step 3: Commit
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
git push origin spa
- Step 3: Mit User besprechen, ob
spa→maingemergt 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) |