/*
RFC-0529: content reference brace-to-braceless migrator — transforms existing
content files by replacing brace-delimited {collection.file.field} patterns with
braceless collection.file.field syntax. Scans all .md and .yaml files under
src/content/. Idempotent: running twice produces the same result.
- Do not resolve references — only syntax transformation.
- Do not migrate files outside src/content/.
- Do not modify generated files (*.generated.yaml, *.credits.yaml, *.manifest.yaml).
- RFC-0529: initial migrator — convert brace-delimited content references to braceless syntax.
*/
import path from "node:path";
import type { Migrator, SternsystemData, MigrationContext } from "./types.ts";
import { MigrationError } from "./types.ts";
import {
createDefaultIO,
type DirEntry,
type WorkspaceIO,
} from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export const RFC_0529_MIGRATOR_ID = "rfc-0529";
const BRACE_REF_PATTERN = /\{([a-z][a-z-]*[./][a-z0-9-/]+\.[a-zA-Z0-9_.-]+)\}/g;
function normalizeRefSeparator(ref: string): string {
return ref.replace(/^([a-z][a-z-]*)\//, "$1.");
}
function migrateString(value: string): string {
if (!value.includes("{")) return value;
let result = value;
const pattern = new RegExp(BRACE_REF_PATTERN.source, "g");
let match: RegExpExecArray | null;
const replacements: Array<{ original: string; replacement: string }> = [];
while ((match = pattern.exec(value)) !== null) {
const fullMatch = match[0];
const inner = match[1];
replacements.push({ original: fullMatch, replacement: normalizeRefSeparator(inner) });
}
for (const { original, replacement } of replacements) {
result = result.replace(original, replacement);
}
return result;
}
function migrateYamlLine(line: string): string {
if (!line.includes("{")) return line;
const valueMatch = line.match(/^(\s*\S+:?\s*)(["'])(.*)\2\s*$/);
if (!valueMatch) {
return migrateString(line);
}
const prefix = valueMatch[1];
const quote = valueMatch[2];
const content = valueMatch[3];
const pureRefPattern = /^[a-z][a-z-]*\.[a-z0-9-/]+\.[a-zA-Z0-9_.-]+$/;
const migratedContent = migrateString(content);
if (pureRefPattern.test(migratedContent)) {
return `${prefix}${migratedContent}`;
}
return `${prefix}${quote}${migratedContent}${quote}`;
}
function splitFrontmatter(content: string): { frontmatter: string; body: string } | null {
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return null;
return { frontmatter: match[1], body: match[2] };
}
function migrateFrontmatter(frontmatter: string): string {
const lines = frontmatter.split("\n");
const result: string[] = [];
for (const line of lines) {
if (line.includes("{")) {
result.push(migrateYamlLine(line));
} else {
result.push(line);
}
}
return result.join("\n");
}
function migrateMarkdownFile(content: string): string {
const parts = splitFrontmatter(content);
if (!parts) {
return migrateString(content);
}
const migratedFm = migrateFrontmatter(parts.frontmatter);
const migratedBody = migrateString(parts.body);
return `---\n${migratedFm}\n---\n${migratedBody}`;
}
function migrateYamlFile(content: string): string {
const lines = content.split("\n");
const result: string[] = [];
for (const line of lines) {
if (line.includes("{")) {
result.push(migrateYamlLine(line));
} else {
result.push(line);
}
}
return result.join("\n");
}
async function scanDir(
dir: string,
ctx: MigrationContext,
isGenerated: (name: string) => boolean,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
let entries: DirEntry[];
try {
entries = await io.readdir(dir);
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory) {
if (entry.name.startsWith("-") || entry.name.startsWith("old-")) continue;
await scanDir(fullPath, ctx, isGenerated);
} else if (entry.isFile) {
if (isGenerated(entry.name)) continue;
if (entry.name.endsWith(".md")) {
await migrateMarkdownFileOnDisk(fullPath, ctx);
} else if (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")) {
await migrateYamlFileOnDisk(fullPath, ctx);
}
}
}
}
function isGeneratedFile(name: string): boolean {
return (
name.endsWith(".generated.yaml") ||
name.endsWith(".generated.yml") ||
name.endsWith(".credits.yaml") ||
name.endsWith(".manifest.yaml")
);
}
async function migrateMarkdownFileOnDisk(
filePath: string,
ctx: MigrationContext,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
let content: string;
try {
content = await io.readFile(filePath);
} catch {
return;
}
if (!content.includes("{")) return;
const migrated = migrateMarkdownFile(content);
if (migrated !== content) {
try {
await io.writeFile(filePath, migrated);
ctx.logger.info(`[rfc-0529] migrated content references in ${filePath}`);
} catch (err) {
throw new MigrationError(
RFC_0529_MIGRATOR_ID,
filePath,
"",
`Failed to migrate content references: ${(err as Error).message}`,
);
}
}
}
async function migrateYamlFileOnDisk(
filePath: string,
ctx: MigrationContext,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
let content: string;
try {
content = await io.readFile(filePath);
} catch {
return;
}
if (!content.includes("{")) return;
const migrated = migrateYamlFile(content);
if (migrated !== content) {
try {
await io.writeFile(filePath, migrated);
ctx.logger.info(`[rfc-0529] migrated content references in ${filePath}`);
} catch (err) {
throw new MigrationError(
RFC_0529_MIGRATOR_ID,
filePath,
"",
`Failed to migrate content references: ${(err as Error).message}`,
);
}
}
}
export const rfc0529Migrator: Migrator = {
id: RFC_0529_MIGRATOR_ID,
fromVersion: "4.19.0",
toVersion: "4.20.0",
description:
"Convert brace-delimited {collection.file.field} content references to braceless collection.file.field syntax in all .md and .yaml files under src/content/. Idempotent. Advances migratorCursor for RFC-0529 (braceless content reference migration).",
transform: async (
data: SternsystemData,
ctx: MigrationContext,
io: WorkspaceIO = createDefaultIO().io,
) => {
const contentDir = path.join(data.rootPath, "src", "content");
try {
await io.stat(contentDir);
} catch {
ctx.logger.info(`[rfc-0529] no content directory at ${contentDir} — skipping`);
return data;
}
await scanDir(contentDir, ctx, isGeneratedFile);
return data;
},
};