feat(content): validate versioned guide blocks
This commit is contained in:
@@ -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";
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user