166 lines
4.5 KiB
TypeScript
166 lines
4.5 KiB
TypeScript
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<string, unknown>;
|
|
responsiveLayout: Record<string, unknown>;
|
|
}
|
|
|
|
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<number, Migration>;
|
|
}
|
|
|
|
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<KnownBlockType, BlockDefinition>;
|
|
|
|
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<string, unknown>,
|
|
responsiveLayout: layoutResult.data,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function validatePageBlocks(
|
|
blocks: readonly SerializedBlock[],
|
|
): BlockResolution[] {
|
|
const ids = new Set<string>();
|
|
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);
|
|
});
|
|
}
|