feat(content): validate versioned guide blocks

This commit is contained in:
2026-08-28 16:17:41 +00:00 Unverified
parent de48230b37
commit 42c80965cf
6 changed files with 803 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
export {
blockRegistry,
isKnownBlockType,
resolveBlock,
validatePageBlocks,
} from "./registry";
export type {
BlockResolution,
InvalidBlock,
ReadyBlock,
SerializedBlock,
UnsupportedBlock,
ValidBlock,
} from "./registry";
export {
blockSchemas,
comparisonChartSchema,
responsiveLayoutSchema,
} from "./schemas";
export type {
ComparisonChartConfig,
KnownBlockType,
ResponsiveLayout,
} from "./schemas";
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { blockRegistry, resolveBlock, validatePageBlocks } from "./registry";
const id = "7d9be7bb-185f-43e8-a5c1-43a05acdebec";
describe("block registry", () => {
it("provides validation and migration for every schema", () => {
for (const definition of Object.values(blockRegistry)) {
expect(definition.currentVersion).toBe(1);
expect(definition.migrations[0]).toBeTypeOf("function");
}
});
it("migrates and validates a legacy block", () => {
const resolution = resolveBlock({
id,
type: "callout",
schemaVersion: 0,
sortOrder: 0,
config: { body: "จังหวะนี้ควรเก็บพลังงานไว้", tone: "tip" },
});
expect(resolution.kind).toBe("ready");
if (resolution.kind === "ready") {
expect(resolution.block.schemaVersion).toBe(1);
expect(resolution.block.responsiveLayout).toMatchObject({
width: "content",
});
}
});
it("keeps unknown and future blocks safe", () => {
expect(
resolveBlock({ id, type: "future-widget", schemaVersion: 1, config: {}, sortOrder: 0 }),
).toMatchObject({ kind: "unsupported", reason: "unknown-type" });
expect(
resolveBlock({ id, type: "callout", schemaVersion: 99, config: {}, sortOrder: 0 }),
).toMatchObject({ kind: "unsupported", reason: "future-version" });
});
it("rejects unsafe or malformed configuration without throwing", () => {
const resolution = resolveBlock({
id,
type: "links",
schemaVersion: 1,
sortOrder: 0,
config: {
links: [
{
id: "2b21ac85-eb39-4f4d-89ce-a26929417981",
label: "ไม่ปลอดภัย",
href: "javascript:alert(1)",
},
],
},
});
expect(resolution.kind).toBe("invalid");
});
it("enforces chart series and an immutable accessible table fallback", () => {
const base = {
id,
type: "comparison-chart",
schemaVersion: 1,
sortOrder: 0,
config: {
dataSourceId: "495248af-d20a-4e59-be25-a982398df917",
categoryField: "name",
series: [{ field: "damage", label: "ดาเมจ", color: "chart-1" }],
presentation: "horizontal-bar",
normalization: "raw",
sort: "authored",
showValues: true,
showLegend: false,
},
} as const;
const valid = resolveBlock(base);
expect(valid.kind).toBe("ready");
if (valid.kind === "ready") {
expect(valid.block.config.tableFallback).toBe(true);
}
expect(
resolveBlock({
...base,
config: { ...base.config, tableFallback: false },
}).kind,
).toBe("invalid");
expect(
resolveBlock({
...base,
config: {
...base.config,
series: Array.from({ length: 7 }, (_, index) => ({
field: `value_${index}`,
label: `Series ${index}`,
color: "chart-1",
})),
},
}).kind,
).toBe("invalid");
});
it("detects duplicate stable ids while preserving author order", () => {
const results = validatePageBlocks([
{
id,
type: "separator",
schemaVersion: 1,
config: {},
sortOrder: 2,
},
{
id,
type: "separator",
schemaVersion: 1,
config: {},
sortOrder: 1,
},
]);
expect(results[0].kind).toBe("ready");
expect(results[1].kind).toBe("invalid");
});
});
+165
View File
@@ -0,0 +1,165 @@
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);
});
}
+283
View File
@@ -0,0 +1,283 @@
import { z } from "zod";
const uuid = z.string().uuid();
const shortText = z.string().trim().min(1).max(160);
const bodyText = z.string().max(20_000);
const anchor = z
.string()
.trim()
.min(1)
.max(120)
.regex(/^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u)
.optional();
const safeLink = z
.string()
.trim()
.max(2_000)
.refine(
(value) => /^(https?:\/\/|\/|#|mailto:)/u.test(value),
"Links must use https, http, mailto, an anchor, or a same-origin path.",
);
export const responsiveLayoutSchema = z.strictObject({
width: z.enum(["content", "wide", "full"]).default("content"),
mobileSpan: z.number().int().min(1).max(4).default(4),
tabletSpan: z.number().int().min(1).max(8).default(8),
desktopSpan: z.number().int().min(1).max(12).default(12),
hideOnMobile: z.boolean().default(false),
});
const badgeSchema = z.strictObject({
label: shortText,
value: z.string().trim().max(160).optional(),
tone: z.enum(["neutral", "element", "rarity", "status"]).default("neutral"),
});
const profileSchema = z.strictObject({
eyebrow: z.string().trim().max(120).optional(),
title: shortText,
subtitle: z.string().trim().max(240).optional(),
summary: bodyText.optional(),
mediaId: uuid.nullable().optional(),
badges: z.array(badgeSchema).max(12).default([]),
});
const richTextNodeSchema = z.discriminatedUnion("type", [
z.strictObject({ type: z.literal("paragraph"), text: bodyText }),
z.strictObject({ type: z.literal("bulleted-list"), items: z.array(bodyText).max(50) }),
z.strictObject({ type: z.literal("numbered-list"), items: z.array(bodyText).max(50) }),
z.strictObject({ type: z.literal("quote"), text: bodyText }),
]);
const richTextSchema = z.strictObject({
content: z.array(richTextNodeSchema).max(200),
});
const headingSchema = z.strictObject({
text: shortText,
level: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(2),
anchor,
});
const calloutSchema = z.strictObject({
title: z.string().trim().max(160).optional(),
body: bodyText,
tone: z.enum(["info", "tip", "warning", "danger"]).default("info"),
});
const statCardSchema = z.strictObject({
id: uuid,
label: shortText,
value: z.string().trim().max(160),
suffix: z.string().trim().max(40).optional(),
description: z.string().trim().max(320).optional(),
});
const statCardsSchema = z.strictObject({
title: z.string().trim().max(160).optional(),
items: z.array(statCardSchema).min(1).max(12),
});
const keyValueSchema = z.strictObject({
title: z.string().trim().max(160).optional(),
items: z
.array(
z.strictObject({
id: uuid,
label: shortText,
value: z.string().trim().max(500),
description: z.string().trim().max(1_000).optional(),
}),
)
.min(1)
.max(40),
columns: z.union([z.literal(1), z.literal(2), z.literal(3)]).default(2),
});
const rankingSchema = z.strictObject({
title: shortText,
description: z.string().max(2_000).optional(),
dataSourceId: uuid,
labelField: shortText,
valueField: shortText,
detailField: z.string().trim().max(80).optional(),
badgeField: z.string().trim().max(80).optional(),
higherIsBetter: z.boolean().default(true),
});
const teamCompositionSchema = z.strictObject({
title: shortText,
dataSourceId: uuid,
teamField: shortText,
memberFields: z.array(shortText).min(1).max(8),
totalField: z.string().trim().max(80).optional(),
notesField: z.string().trim().max(80).optional(),
});
const rotationSchema = z.strictObject({
title: shortText,
description: z.string().max(2_000).optional(),
steps: z
.array(
z.strictObject({
id: uuid,
actor: z.string().trim().max(120).optional(),
action: shortText,
detail: z.string().max(2_000).optional(),
duration: z.string().trim().max(40).optional(),
}),
)
.min(1)
.max(80),
});
const tableSchema = z.strictObject({
title: z.string().trim().max(160).optional(),
dataSourceId: uuid,
columns: z
.array(
z.strictObject({
field: shortText,
label: shortText,
align: z.enum(["start", "center", "end"]).default("start"),
}),
)
.min(1)
.max(20),
});
export const comparisonChartSchema = z.strictObject({
title: z.string().trim().max(160).optional(),
description: z.string().max(2_000).optional(),
dataSourceId: uuid,
categoryField: shortText,
series: z
.array(
z.strictObject({
field: shortText,
label: shortText,
color: z.enum([
"chart-1",
"chart-2",
"chart-3",
"chart-4",
"chart-5",
"chart-6",
]),
}),
)
.min(1)
.max(6),
presentation: z.enum([
"horizontal-bar",
"grouped-bar",
"stacked-bar",
"line",
]),
normalization: z.enum(["raw", "percent-of-total", "baseline-100"]),
sort: z.enum(["authored", "ascending", "descending"]),
showValues: z.boolean().default(true),
showLegend: z.boolean().default(true),
tableFallback: z.literal(true).default(true),
});
const imageSchema = z.strictObject({
mediaId: uuid,
alt: z.string().trim().min(1).max(500),
caption: z.string().max(1_000).optional(),
fit: z.enum(["cover", "contain"]).default("cover"),
});
const gallerySchema = z.strictObject({
title: z.string().trim().max(160).optional(),
images: z
.array(
z.strictObject({
mediaId: uuid,
alt: z.string().trim().min(1).max(500),
caption: z.string().max(1_000).optional(),
}),
)
.min(1)
.max(24),
});
const tabsSchema = z.strictObject({
title: z.string().trim().max(160).optional(),
tabs: z
.array(
z.strictObject({
id: uuid,
label: shortText,
content: bodyText,
}),
)
.min(2)
.max(10),
});
const accordionSchema = z.strictObject({
title: z.string().trim().max(160).optional(),
items: z
.array(
z.strictObject({ id: uuid, title: shortText, content: bodyText }),
)
.min(1)
.max(30),
});
const sectionSchema = z.strictObject({
title: shortText,
description: z.string().max(2_000).optional(),
anchor,
});
const columnsSchema = z.strictObject({
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]),
gap: z.enum(["compact", "normal", "relaxed"]).default("normal"),
});
const linksSchema = z.strictObject({
title: z.string().trim().max(160).optional(),
links: z
.array(
z.strictObject({
id: uuid,
label: shortText,
href: safeLink,
description: z.string().max(500).optional(),
}),
)
.min(1)
.max(30),
});
const separatorSchema = z.strictObject({
label: z.string().trim().max(80).optional(),
});
export const blockSchemas = {
profile: profileSchema,
"rich-text": richTextSchema,
heading: headingSchema,
callout: calloutSchema,
"stat-cards": statCardsSchema,
"key-value": keyValueSchema,
ranking: rankingSchema,
"team-composition": teamCompositionSchema,
rotation: rotationSchema,
table: tableSchema,
"comparison-chart": comparisonChartSchema,
image: imageSchema,
gallery: gallerySchema,
tabs: tabsSchema,
accordion: accordionSchema,
section: sectionSchema,
columns: columnsSchema,
links: linksSchema,
separator: separatorSchema,
} as const;
export type KnownBlockType = keyof typeof blockSchemas;
export type ComparisonChartConfig = z.output<typeof comparisonChartSchema>;
export type ResponsiveLayout = z.output<typeof responsiveLayoutSchema>;
+125
View File
@@ -0,0 +1,125 @@
import { z } from "zod";
import { formulaEngine, type FormulaError } from "@/lib/formula";
import type {
DataSourceField,
DataSourceRow,
DataSourceSnapshot,
} from "./types";
const fieldKeySchema = z
.string()
.min(1)
.max(80)
.regex(
/^[\p{L}_][\p{L}\p{M}\p{N}_]*$/u,
"Field keys may contain letters, marks, numbers, and underscores.",
);
export const dataSourceFieldSchema = z.strictObject({
key: fieldKeySchema,
label: z.string().trim().min(1).max(120),
type: z.enum(["text", "number", "badge", "media"]),
formula: z.string().trim().max(500).nullable().optional(),
displayPrecision: z.number().int().min(0).max(20).optional(),
});
export const dataSourceRowSchema = z.strictObject({
id: z.string().uuid(),
values: z.record(z.string(), z.string().nullable()),
hidden: z.boolean().optional(),
});
export const dataSourceSnapshotSchema = z.strictObject({
id: z.string().uuid(),
name: z.string().trim().min(1).max(160),
version: z.number().int().positive(),
fields: z.array(dataSourceFieldSchema).min(1).max(80),
rows: z.array(dataSourceRowSchema).max(2_000),
});
export interface EvaluatedDataSourceCell {
raw: string | null;
canonical: string | null;
display: string | null;
error?: FormulaError;
}
export interface EvaluatedDataSourceRow {
id: string;
hidden: boolean;
cells: Record<string, EvaluatedDataSourceCell>;
}
export interface EvaluatedDataSource {
source: DataSourceSnapshot;
rows: EvaluatedDataSourceRow[];
valid: boolean;
}
export type { DataSourceField, DataSourceRow, DataSourceSnapshot };
function assertUniqueFieldKeys(fields: readonly DataSourceField[]): void {
const keys = new Set<string>();
for (const field of fields) {
if (keys.has(field.key)) {
throw new Error(`Duplicate data-source field key: ${field.key}`);
}
keys.add(field.key);
}
}
export function evaluateDataSource(
input: DataSourceSnapshot,
): EvaluatedDataSource {
const source = dataSourceSnapshotSchema.parse(input);
assertUniqueFieldKeys(source.fields);
const rows = source.rows.map((row: DataSourceRow): EvaluatedDataSourceRow => {
const numericFields = source.fields.filter(
(field) => field.type === "number" || field.formula,
);
const evaluation = formulaEngine.evaluate(
numericFields.map((field) => ({
key: field.key,
value: row.values[field.key],
formula: field.formula,
displayPrecision: field.displayPrecision,
})),
);
const cells = Object.fromEntries(
source.fields.map((field) => {
const raw = row.values[field.key] ?? null;
if (field.type !== "number" && !field.formula) {
return [
field.key,
{ raw, canonical: raw, display: raw } satisfies EvaluatedDataSourceCell,
];
}
const calculated = evaluation.fields[field.key];
return [
field.key,
{
raw,
canonical: calculated?.canonical ?? null,
display: calculated?.display ?? null,
error: calculated?.error,
} satisfies EvaluatedDataSourceCell,
];
}),
);
return { id: row.id, hidden: row.hidden ?? false, cells };
});
return {
source,
rows,
valid: rows.every((row) =>
Object.values(row.cells).every((cell) => !cell.error),
),
};
}
+78
View File
@@ -0,0 +1,78 @@
export type Visibility = "hidden" | "visible";
export interface CharacterIdentity {
id: string;
slug: string;
nameTh: string;
nameEn: string | null;
description: string | null;
portraitMediaId: string | null;
element: string | null;
role: string | null;
rarity: number | null;
sortOrder: number;
visible: boolean;
}
export interface PageIdentity {
id: string;
characterId: string;
slug: string;
title: string;
navOrder: number;
visible: boolean;
publicNote: string | null;
version: number;
}
export interface DataSourceField {
key: string;
label: string;
type: "text" | "number" | "badge" | "media";
formula?: string | null;
displayPrecision?: number;
}
export interface DataSourceRow {
id: string;
values: Record<string, string | null>;
hidden?: boolean;
}
export interface DataSourceSnapshot {
id: string;
name: string;
version: number;
fields: DataSourceField[];
rows: DataSourceRow[];
}
export interface PageBlockSnapshot {
id: string;
type: string;
schemaVersion: number;
config: unknown;
responsiveLayout?: unknown;
sortOrder: number;
}
export interface PageSnapshot {
character: CharacterIdentity;
page: PageIdentity;
pages: PageIdentity[];
blocks: PageBlockSnapshot[];
dataSources: Record<string, DataSourceSnapshot>;
versionVector: Record<string, number>;
}
export interface PublicCharacterCard {
id: string;
slug: string;
nameTh: string;
nameEn: string | null;
portraitMediaId: string | null;
element: string | null;
role: string | null;
rarity: number | null;
firstPageSlug: string;
}