/*
RFC-0500: ratgeber topics→articles migrator — transforms existing
topics collection records into articles collection records by:
1. Moving src/content/surface/topics/ → src/content/surface/articles/
2. Converting `sections` frontmatter field to prose markdown files under src/content/prose/
3. Renaming frontmatter fields: name→title, intro→summary
4. Adding required fields: status="published", articleType="grundlagenartikel", categoryId
5. Removing FAQ-only entries (entries with only faqs and no sections)
Idempotent: running twice produces no changes on already-migrated files.
- Do not validate article records — that is ratgeber.hub.validate.
- Do not create article-categories — that is operator-authored content.
- RFC-0500: initial migrator — topics→articles collection rename + sections→prose conversion.
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
*/
import path from "node:path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import type { Migrator, SternsystemData, MigrationContext } from "./types.ts";
import { MigrationError } from "./types.ts";
import {
createDefaultIO,
type FileStat,
type WorkspaceIO,
} from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export const RFC_0500_MIGRATOR_ID = "rfc-0500";
interface Section {
heading?: string;
body?: string;
}
function transformFrontmatter(
data: Record,
slug: string,
): Record {
const result = { ...data };
if (result.name !== undefined && result.title === undefined) {
result.title = result.name;
delete result.name;
}
if (result.intro !== undefined && result.summary === undefined) {
result.summary = result.intro;
delete result.intro;
}
if (result.status === undefined) {
result.status = "published";
}
if (result.articleType === undefined) {
result.articleType = "grundlagenartikel";
}
if (result.categoryId === undefined) {
result.categoryId = slug.includes("kosten") ? "kosten" : "sichtbarkeit";
}
return result;
}
function sectionsToProse(sections: Section[]): string {
const parts: string[] = [];
for (const section of sections) {
if (section.heading) parts.push(`## ${section.heading}\n`);
if (section.body) parts.push(`${section.body}\n`);
}
return parts.join("\n");
}
async function findMarkdownFiles(dir: string): Promise {
const results: string[] = [];
// fs.walk.lint: allow — walks via WorkspaceIO abstraction (io.readdir) — collectFiles has no io contract
async function walk(d: string, io: WorkspaceIO = createDefaultIO().io): Promise {
let entries: string[];
try {
entries = (await io.readdir(d)).map((e) => e.name);
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(d, entry);
let stat: FileStat;
try {
stat = await io.stat(fullPath);
} catch {
continue;
}
if (stat.isDirectory) {
await walk(fullPath);
} else if (entry.endsWith(".md")) {
results.push(fullPath);
}
}
}
await walk(dir);
return results;
}
async function dirExists(dir: string, io: WorkspaceIO = createDefaultIO().io): Promise {
try {
const stat = await io.stat(dir);
return stat.isDirectory;
} catch {
return false;
}
}
async function ioFileExists(
file: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
try {
const stat = await io.stat(file);
return stat.isFile;
} catch {
return false;
}
}
const CATEGORY_KOSTEN = `---
slug: kosten
title: Kosten
sortOrder: 1
description: Was eine Website kostet — verständlich aufbereitet für kleines Gewerbe und Handwerk.
---
## Kosten
Artikel rund um Website-Kosten, Preisstrukturen und Budgetplanung für kleine Betriebe.
`;
const CATEGORY_SICHTBARKEIT = `---
slug: sichtbarkeit
title: Sichtbarkeit
sortOrder: 2
description: Lokale Sichtbarkeit, Google-Präsenz und Auffindbarkeit für kleines Gewerbe und Handwerk.
---
## Sichtbarkeit
Artikel rund um lokale Online-Sichtbarkeit, Google Business Profile und Auffindbarkeit.
`;
async function transformTopicsToArticles(
data: SternsystemData,
ctx: MigrationContext,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const topicsDir = path.join(data.rootPath, "src", "content", "surface", "topics");
const articlesDir = path.join(data.rootPath, "src", "content", "surface", "articles");
const proseDir = path.join(data.rootPath, "src", "content", "prose");
const topicsExists = await dirExists(topicsDir);
const articlesExists = await dirExists(articlesDir);
if (!topicsExists && !articlesExists) {
ctx.logger.info(
"[migrator rfc-0500] no topics or articles directory found — nothing to migrate",
);
return data;
}
if (topicsExists && !articlesExists) {
await io.mkdir(articlesDir);
const langDirs = await io.readdir(topicsDir);
for (const langDir of langDirs) {
const srcLangPath = path.join(topicsDir, langDir.name);
const stat = await io.stat(srcLangPath).catch(() => null);
if (!stat || !stat.isDirectory) continue;
const destLangPath = path.join(articlesDir, langDir.name);
await io.mkdir(destLangPath);
const files = await findMarkdownFiles(srcLangPath);
for (const file of files) {
const relName = path.relative(srcLangPath, file);
const destFile = path.join(destLangPath, relName);
await io.mkdir(path.dirname(destFile));
await io.copyFile(file, destFile);
}
}
ctx.logger.info("[migrator rfc-0500] copied topics/ → articles/");
}
const articleFiles = await findMarkdownFiles(articlesDir);
for (const filePath of articleFiles) {
let raw: string;
try {
raw = await io.readFile(filePath);
} catch {
ctx.logger.info(`[migrator rfc-0500] skip unreadable file: ${filePath}`);
continue;
}
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!fmMatch) {
ctx.logger.info(`[migrator rfc-0500] skip non-frontmatter file: ${filePath}`);
continue;
}
let frontmatter: Record;
try {
frontmatter = parseYaml(fmMatch[1]) as Record;
} catch (err) {
throw new MigrationError(
RFC_0500_MIGRATOR_ID,
filePath,
"",
`failed to parse frontmatter YAML: ${(err as Error).message}`,
);
}
if (!frontmatter || typeof frontmatter !== "object") {
ctx.logger.info(`[migrator rfc-0500] skip non-object frontmatter: ${filePath}`);
continue;
}
const sections = frontmatter.sections;
const faqs = frontmatter.faqs;
const hasSections = Array.isArray(sections) && sections.length > 0;
const hasOnlyFaqs = !hasSections && Array.isArray(faqs) && faqs.length > 0;
if (hasOnlyFaqs) {
ctx.logger.info(
`[migrator rfc-0500] removing FAQ-only article: ${path.relative(data.rootPath, filePath)}`,
);
await io.rm(filePath);
continue;
}
const slug = path.basename(filePath, ".md");
const before = JSON.stringify(frontmatter);
const transformed = transformFrontmatter(frontmatter, slug);
const after = JSON.stringify(transformed);
const body = fmMatch[2];
if (hasSections && transformed.sections !== undefined) {
const lang = path.basename(path.dirname(filePath));
const proseFileName = `ratgeber-${lang}-${slug}.md`;
const proseFilePath = path.join(proseDir, proseFileName);
await io.mkdir(proseDir);
const proseContent = sectionsToProse(sections as Section[]);
await io.writeFile(proseFilePath, proseContent);
ctx.logger.info(
`[migrator rfc-0500] wrote prose: ${path.relative(data.rootPath, proseFilePath)}`,
);
delete transformed.sections;
}
if (before !== after || transformed.sections === undefined) {
const output = "---\n" + stringifyYaml(transformed) + "---\n" + body;
await io.writeFile(filePath, output);
ctx.logger.info(`[migrator rfc-0500] transformed: ${path.relative(data.rootPath, filePath)}`);
}
}
if (topicsExists) {
await io.rm(topicsDir, { recursive: true }).catch(() => {
ctx.logger.info("[migrator rfc-0500] could not remove old topics/ directory");
});
}
// RFC-0500: create initial article-categories records if they don't exist
const categoriesDir = path.join(data.rootPath, "src", "content", "surface", "article-categories");
const categoriesExist = await dirExists(categoriesDir);
if (!categoriesExist) {
await io.mkdir(categoriesDir);
const langDirs = await io.readdir(articlesDir).catch(() => []);
for (const langDir of langDirs) {
const langPath = path.join(articlesDir, langDir.name);
const stat = await io.stat(langPath).catch(() => null);
if (!stat || !stat.isDirectory) continue;
const destLangPath = path.join(categoriesDir, langDir.name);
await io.mkdir(destLangPath);
const kostenPath = path.join(destLangPath, "kosten.md");
if (!(await ioFileExists(kostenPath))) {
await io.writeFile(kostenPath, CATEGORY_KOSTEN);
ctx.logger.info(
`[migrator rfc-0500] created category: ${path.relative(data.rootPath, kostenPath)}`,
);
}
const sichtbarkeitPath = path.join(destLangPath, "sichtbarkeit.md");
if (!(await ioFileExists(sichtbarkeitPath))) {
await io.writeFile(sichtbarkeitPath, CATEGORY_SICHTBARKEIT);
ctx.logger.info(
`[migrator rfc-0500] created category: ${path.relative(data.rootPath, sichtbarkeitPath)}`,
);
}
}
}
return data;
}
export const rfc0500Migrator: Migrator = {
id: RFC_0500_MIGRATOR_ID,
fromVersion: "4.12.0",
toVersion: "4.13.0",
description:
"Convert topics collection to articles collection, transform sections to prose markdown files, rename frontmatter fields (name→title, intro→summary), add required fields (status, articleType, categoryId), remove FAQ-only entries",
transform: async (data, ctx) => {
return transformTopicsToArticles(data, ctx);
},
};