22 KiB
Multilinguale Posts — Repo-Struktur & Publish-Pipeline (Plan 1/3)
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: Posts können im Repo unter content/posts/<lang>/<slug>/ liegen; das Deno-Publish-Skript traversiert die neue Struktur korrekt, erzeugt kind:30023-Events mit l-Tag (bereits vorhanden) und optionalen a-Tag-Verweisen auf andere Sprach-Varianten (neu). Die 26 bestehenden Posts werden unter de/ einsortiert.
Architecture: Der einzige Code-Change liegt in publish/ (Deno-Pipeline). Die Traversierungs-Funktionen (allPostDirs, filterPostDirs) lernen eine zusätzliche Verzeichnisebene. buildKind30023 erhält optionale a-Tags aus dem Frontmatter. Die 8 Bestandsposts werden per git mv unter content/posts/de/ verschoben und per publish --force-all re-publisht — selber d-tag, selber Content, nur Event-Erzeugung läuft durch die neue Pipeline.
Tech Stack: Deno, TypeScript, @std/yaml, @std/path, @std/assert für Tests. Bestehende Test-Infrastruktur unter publish/tests/.
Spec-Referenz
Umsetzt die Abschnitte Content-Struktur, Frontmatter, nostr-Event-Mapping, Publish-Pipeline und Migration bestehender Posts aus docs/superpowers/specs/2026-04-21-multilingual-posts-design.md. Out-of-scope in diesem Plan: SPA-Änderungen (Plan 2) und UI-Lokalisierung via svelte-i18n (Plan 3).
Datei-Struktur
Zu ändern:
publish/src/core/change-detection.ts—filterPostDirsundallPostDirslernen Sprach-Ebene.publish/src/core/frontmatter.ts— InterfaceFrontmatterum optionalesa-Feld ergänzen.publish/src/core/event.ts—buildKind30023übernimmta-Tags ausfm.aals["a", coord, "", "translation"].publish/src/core/validation.ts— leichtea-tag-Format-Prüfung.publish/tests/change-detection_test.ts— neue Tests für Sprach-Ebene.publish/tests/event_test.ts— neue Tests füra-tag-Übernahme.publish/tests/frontmatter_test.ts— Test füra-Feld-Parsing.publish/tests/validation_test.ts— Test füra-Format-Fehler.
Repo-Content-Umbau (kein Code):
- Alle 26 Unterordner von
content/posts/→ nachcontent/posts/de/verschieben. - In jedem
index.mdFrontmatter:lang: desicherstellen; auskommentiertena-Platzhalter ergänzen.
Nicht angefasst:
publish/src/subcommands/publish.ts— liestpostDirohnehin als opakes Verzeichnis; erhält die bereits um eine Ebene tiefere Pfade unverändert weitergereicht.publish/src/cli.ts—resolvePostDirsnutztallPostDirs/changedPostDirs, die wir anpassen; keine eigene Änderung nötig, solange Interface gleich bleibt.- GitHub-Action
.github/workflows/publish.yml— ruft CLI mit Env-Variablen, unverändert.
Task 1: Traversierung — Tests für Sprach-Ebene in filterPostDirs
Files:
-
Test:
publish/tests/change-detection_test.ts -
Step 1: Failing Tests schreiben
Ergänze in publish/tests/change-detection_test.ts nach dem letzten bestehenden Test (hinter Deno.test('changedPostDirs: ...', ...)):
Deno.test('filterPostDirs: extrahiert post-ordner mit sprach-ebene', () => {
const lines = [
'content/posts/de/a/index.md',
'content/posts/en/b/image.png',
'content/posts/de/c/index.md',
'README.md',
]
assertEquals(
filterPostDirs(lines, 'content/posts').sort(),
['content/posts/de/a', 'content/posts/de/c', 'content/posts/en/b'],
)
})
Deno.test('filterPostDirs: ignoriert dateien direkt unter lang-ordner', () => {
const lines = [
'content/posts/de/index.md',
'content/posts/de/README.md',
'content/posts/de/x/index.md',
]
assertEquals(filterPostDirs(lines, 'content/posts'), ['content/posts/de/x'])
})
Deno.test('filterPostDirs: _drafts unter sprach-ebene wird ignoriert', () => {
const lines = [
'content/posts/de/_drafts/x/index.md',
'content/posts/de/real/index.md',
]
assertEquals(filterPostDirs(lines, 'content/posts'), ['content/posts/de/real'])
})
- Step 2: Tests laufen, Erwartung FAIL
Run:
cd publish && deno test tests/change-detection_test.ts
Expected: Die drei neuen Tests schlagen fehl (alte Regex kennt nur eine Ebene). Bestehende Tests bleiben grün.
- Step 3: Commit der Tests
git add publish/tests/change-detection_test.ts
git commit -m "test: filterPostDirs für sprach-ebene (failing)"
Task 2: Traversierung — filterPostDirs auf Sprach-Ebene umstellen
Files:
-
Modify:
publish/src/core/change-detection.ts -
Step 1: Regex um Sprach-Ebene erweitern
In publish/src/core/change-detection.ts, ersetze den Block in filterPostDirs ab const indexRe = ... bis return [...dirs].sort() durch:
const indexRe = new RegExp(`^${escapeRegex(prefix)}([a-z]{2})/([^/]+)/index\\.md$`)
const assetRe = new RegExp(`^${escapeRegex(prefix)}([a-z]{2})/([^/]+)/`)
const dirs = new Set<string>()
for (const line of lines) {
const l = line.trim()
if (!l) continue
const indexMatch = l.match(indexRe)
if (indexMatch) {
const [, lang, slug] = indexMatch
if (slug.startsWith('_')) continue
dirs.add(`${prefix}${lang}/${slug}`)
continue
}
const assetMatch = l.match(assetRe)
if (assetMatch && !l.endsWith('.md')) {
const [, lang, slug] = assetMatch
if (slug.startsWith('_')) continue
dirs.add(`${prefix}${lang}/${slug}`)
}
}
return [...dirs].sort()
Und entferne die obsolete Zeile const drafts = prefix + '_' samt der dazugehörigen if (l.startsWith(drafts)) continue — wir filtern jetzt auf Slug-Ebene.
- Step 2: Tests laufen, Erwartung PASS
Run:
cd publish && deno test tests/change-detection_test.ts
Expected: Alle Tests grün. Falls ein bestehender Test (filterPostDirs: extrahiert post-ordner aus dateipfaden (content/posts)) rot wird: Das ist beabsichtigt — er testet die alte flache Struktur. Passe ihn an, indem du die Eingabe-Pfade um /de/ ergänzt:
// alter Test — Eingabe aktualisieren:
Deno.test('filterPostDirs: extrahiert post-ordner aus dateipfaden (content/posts)', () => {
const lines = [
'content/posts/de/a/index.md',
'content/posts/de/b/image.png',
'content/posts/de/c/other.md',
'README.md',
'app/src/lib/x.ts',
]
assertEquals(
filterPostDirs(lines, 'content/posts').sort(),
['content/posts/de/a', 'content/posts/de/b'],
)
})
// alter Test — Eingabe aktualisieren:
Deno.test('filterPostDirs: respektiert alternativen root (blog/)', () => {
const lines = [
'blog/de/x/index.md',
'blog/en/y/pic.png',
'content/posts/de/z/index.md',
'README.md',
]
assertEquals(filterPostDirs(lines, 'blog').sort(), ['blog/de/x', 'blog/en/y'])
})
// alter Test — Eingabe aktualisieren:
Deno.test('filterPostDirs: ignoriert _drafts und non-index.md', () => {
const lines = [
'content/posts/de/a/index.md',
'content/posts/de/a/extra.md',
'content/posts/de/_drafts/x/index.md',
]
assertEquals(filterPostDirs(lines, 'content/posts'), ['content/posts/de/a'])
})
Re-Run deno test tests/change-detection_test.ts → alle PASS.
- Step 3: Commit
git add publish/src/core/change-detection.ts publish/tests/change-detection_test.ts
git commit -m "feat(publish): filterPostDirs traversiert sprach-ebene"
Task 3: Traversierung — allPostDirs auf Sprach-Ebene umstellen
Files:
-
Test:
publish/tests/change-detection_test.ts -
Modify:
publish/src/core/change-detection.ts -
Step 1: Failing Test schreiben
allPostDirs hat bisher keinen Test. Ergänze am Ende von publish/tests/change-detection_test.ts:
import { allPostDirs } from '../src/core/change-detection.ts'
Deno.test('allPostDirs: findet posts in sprach-unterordnern', async () => {
const tmp = await Deno.makeTempDir()
try {
await Deno.mkdir(`${tmp}/de/alpha`, { recursive: true })
await Deno.writeTextFile(`${tmp}/de/alpha/index.md`, '---\n---')
await Deno.mkdir(`${tmp}/de/beta`, { recursive: true })
await Deno.writeTextFile(`${tmp}/de/beta/index.md`, '---\n---')
await Deno.mkdir(`${tmp}/en/alpha`, { recursive: true })
await Deno.writeTextFile(`${tmp}/en/alpha/index.md`, '---\n---')
await Deno.mkdir(`${tmp}/de/_draft/index`, { recursive: true })
await Deno.writeTextFile(`${tmp}/de/_draft/index.md`, '---\n---')
const result = await allPostDirs(tmp)
assertEquals(
result.sort(),
[`${tmp}/de/alpha`, `${tmp}/de/beta`, `${tmp}/en/alpha`].sort(),
)
} finally {
await Deno.remove(tmp, { recursive: true })
}
})
Falls der allPostDirs-Import schon oben in der Datei vorhanden ist (weil der Block zu changedPostDirs ihn mit-importiert): den doppelten Import weglassen und stattdessen die bestehende import { ... } from '../src/core/change-detection.ts'-Zeile erweitern.
- Step 2: Test laufen, Erwartung FAIL
Run:
cd publish && deno test tests/change-detection_test.ts
Expected: Neuer Test schlägt fehl, weil allPostDirs nur eine Ebene tief liest.
- Step 3:
allPostDirsauf Sprach-Ebene anpassen
In publish/src/core/change-detection.ts, ersetze die Funktion allPostDirs komplett durch:
export async function allPostDirs(contentRoot: string): Promise<string[]> {
const result: string[] = []
for await (const langEntry of Deno.readDir(contentRoot)) {
if (!langEntry.isDirectory) continue
if (!/^[a-z]{2}$/.test(langEntry.name)) continue
const langDir = `${contentRoot}/${langEntry.name}`
for await (const postEntry of Deno.readDir(langDir)) {
if (!postEntry.isDirectory) continue
if (postEntry.name.startsWith('_')) continue
const indexPath = `${langDir}/${postEntry.name}/index.md`
try {
const stat = await Deno.stat(indexPath)
if (stat.isFile) result.push(`${langDir}/${postEntry.name}`)
} catch {
// skip folders without index.md
}
}
}
return result.sort()
}
- Step 4: Tests laufen, Erwartung PASS
Run:
cd publish && deno test tests/change-detection_test.ts
Expected: Alle grün.
- Step 5: Commit
git add publish/src/core/change-detection.ts publish/tests/change-detection_test.ts
git commit -m "feat(publish): allPostDirs traversiert sprach-ebene"
Task 4: Frontmatter — a-Feld parsen
Files:
-
Test:
publish/tests/frontmatter_test.ts -
Modify:
publish/src/core/frontmatter.ts -
Step 1: Failing Test schreiben
Ergänze in publish/tests/frontmatter_test.ts (am Ende):
Deno.test('parseFrontmatter: liest a-tag-liste aus frontmatter', () => {
const md = [
'---',
'title: T',
'slug: s',
'date: 2024-01-01',
'a:',
' - "30023:abc:other-slug"',
'---',
'body',
].join('\n')
const { fm } = parseFrontmatter(md)
assertEquals(fm.a, ['30023:abc:other-slug'])
})
Deno.test('parseFrontmatter: a fehlt → undefined', () => {
const md = '---\ntitle: T\nslug: s\ndate: 2024-01-01\n---\nbody'
const { fm } = parseFrontmatter(md)
assertEquals(fm.a, undefined)
})
- Step 2: Test laufen, Erwartung FAIL
Run:
cd publish && deno test tests/frontmatter_test.ts
Expected: Neue Tests schlagen fehl (TypeError auf fm.a, weil Feld im Interface nicht deklariert ist — oder: beide PASS, weil YAML ein Array ohnehin durchreicht. Falls beide PASS: weiter zu Step 3 für die Interface-Deklaration; der Test dokumentiert dann nur das gewollte Verhalten).
- Step 3: Interface
Frontmattererweitern
In publish/src/core/frontmatter.ts, ergänze im Interface Frontmatter vor [key: string]: unknown die Zeile:
a?: string[]
- Step 4: Tests laufen, Erwartung PASS
Run:
cd publish && deno test tests/frontmatter_test.ts
Expected: Alle grün.
- Step 5: Commit
git add publish/src/core/frontmatter.ts publish/tests/frontmatter_test.ts
git commit -m "feat(publish): Frontmatter unterstützt a-tag-liste"
Task 5: Validierung — a-Tag-Format prüfen
Files:
-
Test:
publish/tests/validation_test.ts -
Modify:
publish/src/core/validation.ts -
Step 1: Failing Tests schreiben
Ergänze in publish/tests/validation_test.ts (am Ende):
Deno.test('validatePost: akzeptiert a-tag im korrekten format', () => {
const fm = {
title: 'T',
slug: 'abc',
date: new Date('2024-01-01'),
a: ['30023:abcdef0123456789:other-slug'],
} as Frontmatter
validatePost(fm) // wirft nicht
})
Deno.test('validatePost: lehnt a-tag mit falschem format ab', () => {
const fm = {
title: 'T',
slug: 'abc',
date: new Date('2024-01-01'),
a: ['nur-ein-string'],
} as Frontmatter
assertThrows(() => validatePost(fm), Error, 'invalid a-tag')
})
Deno.test('validatePost: lehnt a-tag mit fehlendem d-tag ab', () => {
const fm = {
title: 'T',
slug: 'abc',
date: new Date('2024-01-01'),
a: ['30023:abcdef:'],
} as Frontmatter
assertThrows(() => validatePost(fm), Error, 'invalid a-tag')
})
Stelle sicher, dass die Imports oben in der Datei assertThrows enthalten (ggf. ergänzen: import { assertEquals, assertThrows } from '@std/assert'). Falls Frontmatter noch nicht importiert ist: import type { Frontmatter } from '../src/core/frontmatter.ts' ergänzen.
- Step 2: Tests laufen, Erwartung FAIL
Run:
cd publish && deno test tests/validation_test.ts
Expected: Die beiden Negativ-Tests schlagen fehl (keine Validierung vorhanden).
- Step 3: Validierung implementieren
In publish/src/core/validation.ts, ergänze vor dem Ende der Funktion validatePost (nach der Date-Prüfung):
if (fm.a !== undefined) {
if (!Array.isArray(fm.a)) {
throw new Error('a must be a list of strings')
}
const coordRe = /^\d+:[0-9a-f]+:[a-z0-9][a-z0-9-]*$/
for (const coord of fm.a) {
if (typeof coord !== 'string' || !coordRe.test(coord)) {
throw new Error(`invalid a-tag: "${coord}" (expected "<kind>:<pubkey-hex>:<d-tag>")`)
}
}
}
- Step 4: Tests laufen, Erwartung PASS
Run:
cd publish && deno test tests/validation_test.ts
Expected: Alle grün.
- Step 5: Commit
git add publish/src/core/validation.ts publish/tests/validation_test.ts
git commit -m "feat(publish): validatePost prüft a-tag-format"
Task 6: Event-Mapping — a-Tags aus Frontmatter übernehmen
Files:
-
Test:
publish/tests/event_test.ts -
Modify:
publish/src/core/event.ts -
Step 1: Failing Test schreiben
Ergänze in publish/tests/event_test.ts (am Ende — falls der Import-Block oben buildKind30023 und Frontmatter noch nicht hat, hinzufügen):
Deno.test('buildKind30023: schreibt a-tags aus frontmatter mit marker "translation"', () => {
const fm = {
title: 'T',
slug: 'abc',
date: new Date('2024-01-01T00:00:00Z'),
lang: 'de',
a: [
'30023:0123456789abcdef:other-slug',
'30023:0123456789abcdef:third-slug',
],
} as Frontmatter
const ev = buildKind30023({
fm,
rewrittenBody: 'body',
coverUrl: undefined,
pubkeyHex: '0123456789abcdef',
clientTag: '',
nowSeconds: 1700000000,
})
const aTags = ev.tags.filter((t) => t[0] === 'a')
assertEquals(aTags, [
['a', '30023:0123456789abcdef:other-slug', '', 'translation'],
['a', '30023:0123456789abcdef:third-slug', '', 'translation'],
])
})
Deno.test('buildKind30023: ohne a im frontmatter keine a-tags im event', () => {
const fm = {
title: 'T',
slug: 'abc',
date: new Date('2024-01-01T00:00:00Z'),
lang: 'de',
} as Frontmatter
const ev = buildKind30023({
fm,
rewrittenBody: 'body',
coverUrl: undefined,
pubkeyHex: '0123456789abcdef',
clientTag: '',
nowSeconds: 1700000000,
})
assertEquals(ev.tags.filter((t) => t[0] === 'a'), [])
})
- Step 2: Test laufen, Erwartung FAIL
Run:
cd publish && deno test tests/event_test.ts
Expected: Erster neuer Test schlägt fehl (keine a-Tag-Erzeugung), zweiter läuft möglicherweise durch.
- Step 3:
buildKind30023erweitern
In publish/src/core/event.ts, ergänze nach dem bestehenden if (clientTag) tags.push(['client', clientTag])-Block und vor if (additionalTags) tags.push(...additionalTags):
if (Array.isArray(fm.a)) {
for (const coord of fm.a) {
tags.push(['a', coord, '', 'translation'])
}
}
- Step 4: Tests laufen, Erwartung PASS
Run:
cd publish && deno test tests/event_test.ts
Expected: Alle grün.
- Step 5: Commit
git add publish/src/core/event.ts publish/tests/event_test.ts
git commit -m "feat(publish): buildKind30023 übernimmt a-tags aus frontmatter"
Task 7: Gesamt-Testlauf & Typ-Check
Files: — (nur Testlauf)
- Step 1: Alle Tests im Publish-Subdir
Run:
cd publish && deno test
Expected: Alle Tests grün. Falls rot: Fehler beheben, bevor die Repo-Migration startet.
- Step 2: Deno-Typecheck gesamtes Publish-Modul
Run:
cd publish && deno check src/cli.ts
Expected: Keine Typ-Fehler.
- Step 3: Kein Commit nötig (reiner Verifikations-Schritt).
Task 8: Repo-Migration — bestehende Posts nach content/posts/de/
Files:
-
Move: alle Unterordner von
content/posts/→content/posts/de/ -
Modify: jeder
content/posts/de/<slug>/index.md(Frontmatter ergänzen) -
Step 1:
de/-Zielordner anlegen
mkdir -p content/posts/de
- Step 2: Alle Post-Ordner verschieben (mit
git mv)
for dir in content/posts/*/; do
name=$(basename "$dir")
[ "$name" = "de" ] && continue
git mv "$dir" "content/posts/de/$name"
done
Expected: git status zeigt 26 Renames unter content/posts/<slug> → content/posts/de/<slug>.
Verifizieren:
ls content/posts/de/ | wc -l
Expected: 26
- Step 3:
lang: dein jedem Frontmatter sicherstellen
Prüfe zunächst, wie viele Posts lang: bereits haben:
for f in content/posts/de/*/index.md; do
head -20 "$f" | grep -q "^lang:" || echo "FEHLT: $f"
done
Expected: Leere Ausgabe (alle haben lang:) oder eine Liste der Dateien, in denen lang: de manuell zu ergänzen ist.
Für jede gelistete Datei: Frontmatter öffnen und lang: de in einer neuen Zeile vor dem schließenden --- ergänzen. (Wenn lang: fehlt, manuell mit Editor ergänzen; kein Skript nötig bei wenigen Dateien.)
- Step 4: Auskommentierten
a-Platzhalter ergänzen
Als Konvention fügen wir in jedem Frontmatter direkt vor dem schließenden --- den Platzhalter ein. Manuell pro Datei (oder via Editor-Makro) — Beispiel am Ende des bestehenden Frontmatter-Blocks:
# a:
# - "30023:<pubkey-hex>:<slug-der-anderssprachigen-variante>"
Überprüfen:
grep -L "^# a:" content/posts/de/*/index.md
Expected: Leere Ausgabe (alle Dateien haben den Platzhalter).
- Step 5: Dry-Run-Publish auf einem einzelnen Post
cd publish && deno run -A src/cli.ts publish --dry-run --post bibel-selfies
Expected: Ausgabe ähnlich dry-run: ../content/posts/de/2025-04-17-bibel-selfies, Exit 0. Falls der Pfad falsch aufgelöst wird: zurück zu Task 2/3, Traversierungs-Logik prüfen.
- Step 6: Commit
git add content/posts/
git commit -m "chore: posts nach content/posts/de/ migriert, a-tag-platzhalter ergänzt"
Task 9: Re-Publish der Bestandsposts
Files: — (kein Code, nur CLI-Aufruf)
- Step 1: Publish-Konfiguration prüfen
Run (im Repo-Root):
cd publish && deno run -A src/cli.ts check
Expected: Alle Checks OK (Bunker, Relays, Blossom). Falls FAIL: erst beheben.
- Step 2: Re-Publish aller Posts mit
--force-all
cd publish && deno run -A src/cli.ts publish --force-all
Expected: 26 Posts durchlaufen, alle mit Status success und action: update (selber d-tag wie zuvor → NIP-33-Replacement greift). Log landet unter publish/logs/publish-<timestamp>.json.
- Step 3: Log-Inspektion
ls -t publish/logs/ | head -1 | xargs -I{} cat publish/logs/{} | head -100
Expected: JSON-Log zeigt pro Post l-Tag im Event (sichtbar als ["l", "de", "ISO-639-1"] im Event-Dump, falls im Log enthalten) und keine Fehler. Falls ein Post auf action: new statt update landet, ist das ein Hinweis auf geänderten d-tag — prüfen.
- Step 4: Kein neuer Commit nötig — Re-Publish ändert nur nostr-Events, kein Repo-Zustand.
Task 10: Ende-zu-Ende-Verifikation
Files: — (Verifikation)
- Step 1: Prüfe auf einem Relay, dass einer der Events den
l-Tag trägt
Wähle einen Post (z. B. bibel-selfies) und frage über nak oder einen Nostr-Client das neueste Event mit kind:30023, author=<pubkey>, d=bibel-selfies ab. Bestätige die Tags enthalten:
["L", "ISO-639-1"]
["l", "de", "ISO-639-1"]
Falls nicht verfügbar: Dump aus dem Publish-Log (publish/logs/publish-*.json, Feld eventId) nutzen und das Event via Nostr-Tool oder existierender SPA prüfen.
- Step 2: GitHub-Action smoke-test
Commit einen harmlosen Änderung in einem einzelnen Post (z. B. ein Leerzeichen im Body) und push:
echo "" >> content/posts/de/2025-04-17-bibel-selfies/index.md
git commit -am "test: trigger github-action nach struktur-migration"
git push
Expected: GitHub-Action (publish.yml) läuft durch, findet den einen geänderten Post über changedPostDirs, re-publisht erfolgreich. Logs unter Actions-Tab prüfen.
Falls die Action fehlschlägt, meist: filterPostDirs liest git diff-Zeilen anders als im Test angenommen — zurück zu Task 2.
- Step 3: Kein Commit nötig (der Test-Commit aus Step 2 bleibt).
Fertig
Nach Task 10:
- Repo-Struktur ist
content/posts/<lang>/<slug>/— lauffähig und testbar. - Publish-Pipeline traversiert die neue Struktur korrekt, erzeugt
l- unda-Tags aus dem Frontmatter. - 20 Bestandsposts sind unter
de/einsortiert und frisch re-publisht; Event-Zustand auf Relays ist konsistent mit Repo-Zustand. - GitHub-Action läuft weiterhin automatisch.
Nächster Plan (separat zu schreiben): SPA liest a-Tags, zeigt „Also available in …"-Hinweis, Sprachwahl-Umschalter.