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