import type { z } from "zod"; import { blockSchemas, responsiveLayoutSchema, type KnownBlockType, } from "./schemas"; export interface SerializedBlock { id: string; type: string; schemaVersion: number; config: unknown; responsiveLayout?: unknown; sortOrder: number; } export interface ValidBlock extends SerializedBlock { type: KnownBlockType; schemaVersion: 1; config: Record; responsiveLayout: Record; } export interface UnsupportedBlock { kind: "unsupported"; block: SerializedBlock; reason: "unknown-type" | "future-version"; } export interface InvalidBlock { kind: "invalid"; block: SerializedBlock; issues: z.core.$ZodIssue[]; } export interface ReadyBlock { kind: "ready"; block: ValidBlock; } export type BlockResolution = ReadyBlock | InvalidBlock | UnsupportedBlock; type Migration = (legacyConfig: unknown) => unknown; interface BlockDefinition { currentVersion: 1; schema: z.ZodType; migrations: Record; } const migrateLegacyConfig: Migration = (legacyConfig) => legacyConfig; function defineBlock(schema: z.ZodType): BlockDefinition { return { currentVersion: 1, schema, // Version zero was the pre-release shape. Every registered block gets an // explicit step so future migrations remain sequential and testable. migrations: { 0: migrateLegacyConfig }, }; } export const blockRegistry = { profile: defineBlock(blockSchemas.profile), "rich-text": defineBlock(blockSchemas["rich-text"]), heading: defineBlock(blockSchemas.heading), callout: defineBlock(blockSchemas.callout), "stat-cards": defineBlock(blockSchemas["stat-cards"]), "key-value": defineBlock(blockSchemas["key-value"]), ranking: defineBlock(blockSchemas.ranking), "team-composition": defineBlock(blockSchemas["team-composition"]), rotation: defineBlock(blockSchemas.rotation), table: defineBlock(blockSchemas.table), "comparison-chart": defineBlock(blockSchemas["comparison-chart"]), image: defineBlock(blockSchemas.image), gallery: defineBlock(blockSchemas.gallery), tabs: defineBlock(blockSchemas.tabs), accordion: defineBlock(blockSchemas.accordion), section: defineBlock(blockSchemas.section), columns: defineBlock(blockSchemas.columns), links: defineBlock(blockSchemas.links), separator: defineBlock(blockSchemas.separator), } satisfies Record; export function isKnownBlockType(type: string): type is KnownBlockType { return Object.hasOwn(blockRegistry, type); } export function resolveBlock(block: SerializedBlock): BlockResolution { if (!isKnownBlockType(block.type)) { return { kind: "unsupported", block, reason: "unknown-type" }; } const definition = blockRegistry[block.type]; if (block.schemaVersion > definition.currentVersion) { return { kind: "unsupported", block, reason: "future-version" }; } let config = block.config; for ( let version = Math.max(0, block.schemaVersion); version < definition.currentVersion; version += 1 ) { const migration = definition.migrations[version]; if (!migration) { return { kind: "unsupported", block, reason: "future-version" }; } config = migration(config); } const configResult = definition.schema.safeParse(config); const layoutResult = responsiveLayoutSchema.safeParse( block.responsiveLayout ?? {}, ); if (!configResult.success || !layoutResult.success) { return { kind: "invalid", block, issues: [ ...(configResult.success ? [] : configResult.error.issues), ...(layoutResult.success ? [] : layoutResult.error.issues), ], }; } return { kind: "ready", block: { ...block, type: block.type, schemaVersion: 1, config: configResult.data as Record, responsiveLayout: layoutResult.data, }, }; } export function validatePageBlocks( blocks: readonly SerializedBlock[], ): BlockResolution[] { const ids = new Set(); return [...blocks] .sort((left, right) => left.sortOrder - right.sortOrder) .map((block) => { if (ids.has(block.id)) { return { kind: "invalid", block, issues: [ { code: "custom", path: ["id"], message: `Duplicate stable block id: ${block.id}`, input: block.id, }, ], } satisfies InvalidBlock; } ids.add(block.id); return resolveBlock(block); }); }