/* Migrator RFC-0483: replaces all {business.*} content references with {business-profile.*} equivalents, creates missing de/ PBP entities, populates presentation fields from legacy business data, removes the stopgap business collection from content.config.ts, and deletes the legacy business/ directory. Does not modify PBP schemas — presentation fields are additive (RFC-0482). Does not create uk/ PBP entities — they already exist from RFC-0481. Does not translate de/ content — copies uk/ files as structural templates; operator edits translations. RFC-0483: initial migrator — reference migration + stopgap collection removal. */ import path from "node:path"; import type { Migrator, SternsystemData, MigrationContext } from "./types.ts"; import { MigrationError } from "./types.ts"; import { parseFrontmatter, serializeFrontmatter } from "./yaml-utils.ts"; import { readLocalesSafe } from "./rfc-0481.ts"; import { createDefaultIO, type DirEntry, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export const RFC_0483_MIGRATOR_ID = "rfc-0483"; const REFERENCE_MAPPINGS: Record = { "{business.legal.companyName}": "{business-profile.organization/legal-identity.legalName}", "{business.legal.owner.fullName}": "{business-profile.organization/legal-identity.responsiblePerson.name}", "{business.legal.owner.address.street}": "{business-profile.places/backnang.address.street}", "{business.legal.owner.address.streetNumber}": "{business-profile.places/backnang.address.streetNumber}", "{business.legal.owner.address.zip}": "{business-profile.places/backnang.address.postalCode}", "{business.legal.owner.address.city}": "{business-profile.places/backnang.address.locality}", "{business.contact.email}": "{business-profile.contact/general-email.value}", "{business.contact.supportEmail}": "{business-profile.contact/general-email.value}", "{business.offer.price.monthly}": "{business-profile.offerings/digital-foundation.presentation.price.monthly}", "{business.offer.price.yearly}": "{business-profile.offerings/digital-foundation.presentation.price.yearly}", "{business.offer.price.setup}": "{business-profile.offerings/digital-foundation.presentation.price.setup}", "{business.offer.price.monthlyAmount}": "{business-profile.offerings/digital-foundation.presentation.price.monthlyAmount}", "{business.offer.price.yearlyAmount}": "{business-profile.offerings/digital-foundation.presentation.price.yearlyAmount}", "{business.offer.price.setupAmount}": "{business-profile.offerings/digital-foundation.presentation.price.setupAmount}", "{business.offer.price.moduleVisibilityAmount}": "{business-profile.offerings/digital-foundation.presentation.price.moduleVisibilityAmount}", "{business.offer.price.moduleBookingAmount}": "{business-profile.offerings/digital-foundation.presentation.price.moduleBookingAmount}", "{business.offer.price.moduleTrustAmount}": "{business-profile.offerings/digital-foundation.presentation.price.moduleTrustAmount}", "{business.offer.price.moduleMultilangAmount}": "{business-profile.offerings/digital-foundation.presentation.price.moduleMultilangAmount}", "{business.offer.price.moduleAutomationAmount}": "{business-profile.offerings/digital-foundation.presentation.price.moduleAutomationAmount}", "{business.offer.guarantees.delivery.label}": "{business-profile.offerings/digital-foundation.presentation.guarantees.delivery.label}", "{business.offer.guarantees.delivery.detail}": "{business-profile.offerings/digital-foundation.presentation.guarantees.delivery.detail}", "{business.offer.guarantees.uptime.label}": "{business-profile.offerings/digital-foundation.presentation.guarantees.uptime.label}", "{business.offer.guarantees.uptime.detail}": "{business-profile.offerings/digital-foundation.presentation.guarantees.uptime.detail}", "{business.offer.guarantees.smallChanges.label}": "{business-profile.offerings/digital-foundation.presentation.guarantees.smallChanges.label}", "{business.offer.guarantees.smallChanges.detail}": "{business-profile.offerings/digital-foundation.presentation.guarantees.smallChanges.detail}", "{business.offer.guarantees.response.label}": "{business-profile.offerings/digital-foundation.presentation.guarantees.response.label}", "{business.offer.guarantees.response.detail}": "{business-profile.offerings/digital-foundation.presentation.guarantees.response.detail}", "{business.offer.guarantees.dataPackage.label}": "{business-profile.offerings/digital-foundation.presentation.guarantees.dataPackage.label}", "{business.offer.guarantees.dataPackage.detail}": "{business-profile.offerings/digital-foundation.presentation.guarantees.dataPackage.detail}", "{business.offer.capacity.display.label}": "{business-profile.offerings/digital-foundation.presentation.capacity.display.label}", "{business.offer.capacity.display.rangeLabel}": "{business-profile.offerings/digital-foundation.presentation.capacity.display.rangeLabel}", "{business.offer.capacity.display.unknownAvailabilityLabel}": "{business-profile.offerings/digital-foundation.presentation.capacity.display.unknownAvailabilityLabel}", "{business.offer.growthModules.visibility.label}": "{business-profile.offerings/digital-foundation.presentation.growthModules.visibility.label}", "{business.offer.growthModules.visibility.price}": "{business-profile.offerings/digital-foundation.presentation.growthModules.visibility.price}", "{business.offer.growthModules.booking.label}": "{business-profile.offerings/digital-foundation.presentation.growthModules.booking.label}", "{business.offer.growthModules.booking.price}": "{business-profile.offerings/digital-foundation.presentation.growthModules.booking.price}", "{business.offer.growthModules.trust.label}": "{business-profile.offerings/digital-foundation.presentation.growthModules.trust.label}", "{business.offer.growthModules.trust.price}": "{business-profile.offerings/digital-foundation.presentation.growthModules.trust.price}", "{business.offer.growthModules.multilingual.label}": "{business-profile.offerings/digital-foundation.presentation.growthModules.multilingual.label}", "{business.offer.growthModules.multilingual.price}": "{business-profile.offerings/digital-foundation.presentation.growthModules.multilingual.price}", "{business.offer.growthModules.automation.label}": "{business-profile.offerings/digital-foundation.presentation.growthModules.automation.label}", "{business.offer.growthModules.automation.price}": "{business-profile.offerings/digital-foundation.presentation.growthModules.automation.price}", "{business.offer.changePrice}": "{business-profile.offerings/digital-foundation.presentation.changePrice}", "{business.offer.hourlyRate}": "{business-profile.offerings/digital-foundation.presentation.hourlyRate}", "{business.offer.billingDay}": "{business-profile.offerings/digital-foundation.presentation.billingDay}", "{business.legal.tax.taxNumber}": "{business-profile.organization/legal-identity.presentation.tax.taxNumber}", "{business.legal.tax.vatIdOrSmallBusinessNote}": "{business-profile.organization/legal-identity.presentation.tax.vatIdOrSmallBusinessNote}", "{business.web.domains.primary}": "{business-profile.web/primary.presentation.domains.primary}", "{business.meta.agbEffectiveDate}": "{business-profile.documents/terms.presentation.dates.effectiveDate}", "{business.meta.agbNextReviewDate}": "{business-profile.documents/terms.presentation.dates.nextReviewDate}", "{business.meta.datenschutzCreationDate}": "{business-profile.documents/privacy.presentation.dates.creationDate}", "{business.meta.impressumLastUpdateDate}": "{business-profile.documents/imprint.presentation.dates.lastUpdateDate}", "{business.meta.barrierefreiheitCreationDate}": "{business-profile.documents/legal-notice.presentation.dates.creationDate}", "{business.meta.barrierefreiheitLastReviewDate}": "{business-profile.documents/legal-notice.presentation.dates.lastReviewDate}", "{business.meta.widerrufCreationDate}": "{business-profile.documents/terms.presentation.dates.widerrufCreationDate}", "{business.meta.widerrufFormCreationDate}": "{business-profile.documents/terms.presentation.dates.widerrufFormCreationDate}", "{business.platform-comparison.display.pageText}": "{business-profile/business.presentation.platformComparison.display.pageText}", "{business.platform-comparison.display.disclosure}": "{business-profile/business.presentation.platformComparison.display.disclosure}", "{business.services.websiteDevelopment.backupRetentionDays}": "{business-profile/business.presentation.services.websiteDevelopment.backupRetentionDays}", "{business.external-services.chatbotPlatform}": "{business-profile/business.presentation.externalServices.chatbotPlatform}", }; const BUSINESS_REF_PATTERN = /\{business\.[^}]+\}/g; async function readMarkdownFiles( dir: string, baseDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const results: string[] = []; let entries: DirEntry[]; try { entries = await io.readdir(dir); } catch { return results; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory) { const nested = await readMarkdownFiles(fullPath, baseDir); results.push(...nested); } else if (entry.name.endsWith(".md")) { results.push(fullPath); } } return results; } function replaceReferencesInText(text: string, ctx: MigrationContext): string { return text.replace(BUSINESS_REF_PATTERN, (match) => { if (match === "{business.offer.*}") return match; const replacement = REFERENCE_MAPPINGS[match]; if (!replacement) { throw new MigrationError(RFC_0483_MIGRATOR_ID, "", match, "Unmapped {business.*} reference"); } ctx.logger.info(`[migrator rfc-0483] replacing ${match} → ${replacement}`); return replacement; }); } function replaceReferencesInFile(raw: string, ctx: MigrationContext): string { return replaceReferencesInText(raw, ctx); } async function migrateReferences( rootPath: string, ctx: MigrationContext, io: WorkspaceIO = createDefaultIO().io, ): Promise { const contentDir = path.join(rootPath, "src", "content"); const mdFiles = await readMarkdownFiles(contentDir, contentDir); for (const filePath of mdFiles) { const raw = await io.readFile(filePath); const updated = replaceReferencesInFile(raw, ctx); if (updated !== raw) { await io.writeFile(filePath, updated); ctx.logger.info( `[migrator rfc-0483] migrated references in ${path.relative(rootPath, filePath)}`, ); } } } async function copyPbpEntitiesFromUk( rootPath: string, ctx: MigrationContext, io: WorkspaceIO = createDefaultIO().io, ): Promise { const bpDir = path.join(rootPath, "src", "content", "business-profile"); const ukDir = path.join(bpDir, "uk"); const deDir = path.join(bpDir, "de"); const ukEntries = await readMarkdownFiles(ukDir, ukDir); for (const ukFile of ukEntries) { const relPath = path.relative(ukDir, ukFile); const deFile = path.join(deDir, relPath); if (!(await io.exists(deFile))) { const content = await io.readFile(ukFile); await io.mkdir(path.dirname(deFile)); await io.writeFile(deFile, content); ctx.logger.info(`[migrator rfc-0483] created de/${relPath} from uk/ template`); } } } async function populatePresentationFields( rootPath: string, ctx: MigrationContext, io: WorkspaceIO = createDefaultIO().io, ): Promise { const businessDir = path.join(rootPath, "src", "content", "business"); const bpDir = path.join(rootPath, "src", "content", "business-profile"); for (const lang of await readLocalesSafe(rootPath, ctx)) { const legacyDir = path.join(businessDir, lang); let legacyFiles: string[]; try { legacyFiles = await readMarkdownFiles(legacyDir, legacyDir); } catch { continue; } if (legacyFiles.length === 0) continue; for (const legacyFile of legacyFiles) { const fileName = path.basename(legacyFile, ".md"); const legacyRaw = await io.readFile(legacyFile); const { frontmatter } = parseFrontmatter(legacyRaw); const presentation = extractPresentation(fileName, frontmatter); if (Object.keys(presentation).length === 0) continue; const targetPath = resolvePresentationTarget(fileName, bpDir, lang); if (!targetPath) continue; try { const targetRaw = await io.readFile(targetPath); const { frontmatter: targetFm, body } = parseFrontmatter(targetRaw); const existingPresentation = (targetFm.presentation as Record) ?? {}; const merged = mergePresentation(existingPresentation, presentation); targetFm.presentation = merged; const updated = serializeFrontmatter(targetFm) + body; await io.writeFile(targetPath, updated); ctx.logger.info( `[migrator rfc-0483] populated presentation.* on ${path.relative(rootPath, targetPath)}`, ); } catch { ctx.logger.info( `[migrator rfc-0483] target ${path.relative(rootPath, targetPath)} not found — skipping presentation for ${fileName}`, ); } } } } function extractPresentation( fileName: string, frontmatter: Record, ): Record { const result: Record = {}; if (fileName === "offer") { const offer = frontmatter as Record; if (offer.price) result.price = offer.price; if (offer.guarantees) result.guarantees = offer.guarantees; if (offer.capacity) result.capacity = offer.capacity; if (offer.growthModules) result.growthModules = offer.growthModules; if (offer.changePrice) result.changePrice = offer.changePrice; if (offer.hourlyRate) result.hourlyRate = offer.hourlyRate; if (offer.billingDay) result.billingDay = offer.billingDay; } else if (fileName === "legal") { const legal = frontmatter as Record; const tax = (legal.tax as Record) ?? {}; const presentationTax: Record = {}; if (tax.taxNumber) presentationTax.taxNumber = tax.taxNumber; if (tax.vatIdOrSmallBusinessNote) presentationTax.vatIdOrSmallBusinessNote = tax.vatIdOrSmallBusinessNote; if (Object.keys(presentationTax).length > 0) result.tax = presentationTax; } else if (fileName === "web") { const web = frontmatter as Record; const domains = (web.domains as Record) ?? {}; if (Object.keys(domains).length > 0) result.domains = domains; } else if (fileName === "meta") { const meta = frontmatter as Record; const dates: Record = {}; if (meta.agbEffectiveDate) dates.effectiveDate = meta.agbEffectiveDate; if (meta.agbNextReviewDate) dates.nextReviewDate = meta.agbNextReviewDate; if (meta.datenschutzCreationDate) dates.creationDate = meta.datenschutzCreationDate; if (meta.impressumLastUpdateDate) dates.lastUpdateDate = meta.impressumLastUpdateDate; if (meta.barrierefreiheitCreationDate) dates.creationDate = meta.barrierefreiheitCreationDate; if (meta.barrierefreiheitLastReviewDate) dates.lastReviewDate = meta.barrierefreiheitLastReviewDate; if (meta.widerrufCreationDate) dates.widerrufCreationDate = meta.widerrufCreationDate; if (meta.widerrufFormCreationDate) dates.widerrufFormCreationDate = meta.widerrufFormCreationDate; if (Object.keys(dates).length > 0) result.dates = dates; } else if (fileName === "external-services") { const ext = frontmatter as Record; if (ext.chatbotPlatform) { const es: Record = {}; es.chatbotPlatform = ext.chatbotPlatform; result.externalServices = es; } } else if (fileName === "company") { const company = frontmatter as Record; if (company.platformComparison) result.platformComparison = company.platformComparison; if (company.services) result.services = company.services; } return result; } function resolvePresentationTarget(fileName: string, bpDir: string, lang: string): string | null { const targets: Record = { offer: path.join(bpDir, lang, "offerings", "digital-foundation.md"), legal: path.join(bpDir, lang, "organization", "legal-identity.md"), web: path.join(bpDir, lang, "web", "primary.md"), "external-services": path.join(bpDir, lang, "business.md"), company: path.join(bpDir, lang, "business.md"), meta: path.join(bpDir, lang, "documents", "terms.md"), }; return targets[fileName] ?? null; } function mergePresentation( existing: Record, incoming: Record, ): Record { const result = { ...existing }; for (const [key, value] of Object.entries(incoming)) { if ( typeof value === "object" && value !== null && !Array.isArray(value) && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key]) ) { result[key] = mergePresentation( result[key] as Record, value as Record, ); } else { result[key] = value; } } return result; } async function removeBusinessCollectionFromConfig( rootPath: string, ctx: MigrationContext, io: WorkspaceIO = createDefaultIO().io, ): Promise { const configPath = path.join(rootPath, "src", "content.config.ts"); let raw: string; try { raw = await io.readFile(configPath); } catch { return; } let updated = raw; const businessCollectionPattern = /\/\/ Loads business entity records[\s\S]*?const business = defineCollection\(\{[\s\S]*?\}\);\n*/; updated = updated.replace(businessCollectionPattern, ""); updated = updated.replace(/^\s*business,\s*$/m, ""); updated = updated.replace( /import\s*\{[^}]*businessCollections[^}]*\}\s*from\s*"@warpgogol\/business\/astro";\n*/g, "", ); if (updated !== raw) { await io.writeFile(configPath, updated); ctx.logger.info(`[migrator rfc-0483] removed business collection from content.config.ts`); } } async function deleteBusinessDirectory( rootPath: string, ctx: MigrationContext, io: WorkspaceIO = createDefaultIO().io, ): Promise { const businessDir = path.join(rootPath, "src", "content", "business"); const contentDir = path.join(rootPath, "src", "content"); const mdFiles = await readMarkdownFiles(contentDir, contentDir); for (const filePath of mdFiles) { const raw = await io.readFile(filePath); const matches = raw.match(BUSINESS_REF_PATTERN); if (matches) { const realRefs = matches.filter((m) => m !== "{business.offer.*}"); if (realRefs.length > 0) { throw new MigrationError( RFC_0483_MIGRATOR_ID, path.relative(rootPath, filePath), "", `Cannot delete business/ — ${realRefs.length} unresolved {business.*} references remain`, ); } } } try { await io.rm(businessDir, { recursive: true }); ctx.logger.info(`[migrator rfc-0483] deleted src/content/business/ directory`); } catch { ctx.logger.info(`[migrator rfc-0483] business/ directory already absent`); } } const OLD_FORMAT_FILES = ["company.md", "contact.md", "location.md", "web.md"]; async function deleteOldFormatFiles( rootPath: string, ctx: MigrationContext, io: WorkspaceIO = createDefaultIO().io, ): Promise { const bpDir = path.join(rootPath, "src", "content", "business-profile"); for (const lang of await readLocalesSafe(rootPath, ctx)) { for (const fileName of OLD_FORMAT_FILES) { const filePath = path.join(bpDir, lang, fileName); try { const raw = await io.readFile(filePath); if (!raw.includes("schema:")) { await io.rm(filePath); ctx.logger.info( `[migrator rfc-0483] deleted old-format ${path.relative(rootPath, filePath)}`, ); } } catch { // File doesn't exist or is PBP-format — skip } } } } async function transformReferences( data: SternsystemData, ctx: MigrationContext, ): Promise { await migrateReferences(data.rootPath, ctx); await copyPbpEntitiesFromUk(data.rootPath, ctx); await populatePresentationFields(data.rootPath, ctx); await removeBusinessCollectionFromConfig(data.rootPath, ctx); await deleteBusinessDirectory(data.rootPath, ctx); await deleteOldFormatFiles(data.rootPath, ctx); return data; } export const rfc0483Migrator: Migrator = { id: RFC_0483_MIGRATOR_ID, fromVersion: "4.5.0", toVersion: "4.6.0", description: "Migrate {business.*} content references to {business-profile.*} and remove stopgap collection", transform: async (data, ctx) => { return transformReferences(data, ctx); }, };