diff --git a/lib/blocks/index.ts b/lib/blocks/index.ts new file mode 100644 index 0000000..e7a9919 --- /dev/null +++ b/lib/blocks/index.ts @@ -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"; diff --git a/lib/blocks/registry.test.ts b/lib/blocks/registry.test.ts new file mode 100644 index 0000000..3737da0 --- /dev/null +++ b/lib/blocks/registry.test.ts @@ -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"); + }); +}); diff --git a/lib/blocks/registry.ts b/lib/blocks/registry.ts new file mode 100644 index 0000000..d349088 --- /dev/null +++ b/lib/blocks/registry.ts @@ -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; + 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); + }); +} diff --git a/lib/blocks/schemas.ts b/lib/blocks/schemas.ts new file mode 100644 index 0000000..000db47 --- /dev/null +++ b/lib/blocks/schemas.ts @@ -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; +export type ResponsiveLayout = z.output; diff --git a/lib/content/data-source.ts b/lib/content/data-source.ts new file mode 100644 index 0000000..94744a5 --- /dev/null +++ b/lib/content/data-source.ts @@ -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; +} + +export interface EvaluatedDataSource { + source: DataSourceSnapshot; + rows: EvaluatedDataSourceRow[]; + valid: boolean; +} + +export type { DataSourceField, DataSourceRow, DataSourceSnapshot }; + +function assertUniqueFieldKeys(fields: readonly DataSourceField[]): void { + const keys = new Set(); + 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), + ), + }; +} diff --git a/lib/content/types.ts b/lib/content/types.ts new file mode 100644 index 0000000..df23b69 --- /dev/null +++ b/lib/content/types.ts @@ -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; + 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; + versionVector: Record; +} + +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; +}