/** * @factory/shared — block DTOs (PRODUCT-SPEC §4, §6; prisma `Block`). * * The block tree is the canonical editor model: stable ids + fractional * `position` (never array indexes). `content` is type-specific JSONB * validated against a per-type schema map (see {@link BLOCK_CONTENT_SCHEMAS}); * cross-cutting presentation (`color`, `backgroundColor`, `checked`, …) * lives in `properties`. */ import { z } from "zod"; import { LIMITS } from "../constants.js"; /** * All block types (lowercase snake_case, per PRODUCT-SPEC §4 and the prisma * `BlockType` enum — 30 values). */ export const blockTypeSchema = z.enum([ "paragraph", "heading_1", "heading_2", "heading_3", "quote", "callout", "bulleted_list", "numbered_list", "todo", "toggle", "image", "video", "audio", "file", "bookmark", "embed", "code", "equation", "column", "column_list", "divider", "spacer", "database", "linked_database", "table", "chart", "form", "synced_block", "child_page", "child_database", // T-147: table-of-contents. Content is ALWAYS `{}` — the renderer // generates the entries from the page's headings at render time (the // block is a marker, not a data carrier). Additive: existing docs have // no toc blocks and parse unchanged. "toc", ]); export type BlockType = z.infer; // T-148: callout visual variant (legacy callouts omit it → "info"). export const CALLOUT_VARIANTS = ["info", "warning", "tip", "error"] as const; export type CalloutVariant = (typeof CALLOUT_VARIANTS)[number]; const blockText = z.string().max(LIMITS.MAX_BLOCK_TEXT, "Block text is too long"); const fileId = z.string().min(1); const url = z.string().min(1).url(); // --- per-type content schemas ------------------------------------------------ const textContent = z.object({ text: blockText }).strict(); // T-148: callout with an optional visual variant. `.strict()` keeps unknown // keys rejected; `variant` is additive so every legacy `{ text }` callout // still parses (variant = "info" by convention). const calloutContent = z .object({ text: blockText, variant: z.enum(CALLOUT_VARIANTS).optional(), }) .strict(); const codeContent = z .object({ text: blockText, language: z.string().min(1).max(32).optional(), }) .strict(); const dimensionContent = z .object({ height: z.number().int().positive().max(1000).optional(), }) .strict(); const mediaContent = z .object({ fileId, caption: z.string().max(500).optional(), width: z.number().int().positive().max(4096).optional(), }) .strict(); const bookmarkContent = z .object({ url, title: z.string().max(300).optional(), }) .strict(); const tableContent = z .object({ rows: z .array(z.array(z.string().max(LIMITS.MAX_BLOCK_TEXT))) .min(1) .max(1000), width: z.number().int().positive().max(4096).optional(), }) .strict(); const databaseContent = z.object({ databaseId: fileId }).strict(); const linkedDatabaseContent = z .object({ databaseId: fileId, viewId: fileId.optional(), }) .strict(); const columnContent = z .object({ width: z.number().int().positive().max(4096).optional(), }) .strict(); const columnListContent = z .object({ direction: z.enum(["row", "column"]).default("row"), }) .strict(); /** * Per-type content schema map. `BlockPayload.content` is validated against * the schema selected by `payload.type`. */ export const BLOCK_CONTENT_SCHEMAS = { paragraph: textContent, heading_1: textContent, heading_2: textContent, heading_3: textContent, quote: textContent, callout: calloutContent, bulleted_list: textContent, numbered_list: textContent, todo: textContent, toggle: textContent, image: mediaContent, video: mediaContent, audio: mediaContent, file: mediaContent, bookmark: bookmarkContent, embed: z.object({ url }).strict(), code: codeContent, equation: z.object({ text: blockText }).strict(), column: columnContent, column_list: columnListContent, divider: dimensionContent, spacer: dimensionContent, database: databaseContent, linked_database: linkedDatabaseContent, table: tableContent, chart: z.object({ chartId: fileId }).strict(), form: z.object({ formId: fileId }).strict(), synced_block: z.object({ sourceBlockId: fileId }).strict(), child_page: z.object({ pageId: fileId }).strict(), child_database: databaseContent, // T-147: toc is a pure marker — no content (the renderer derives entries). toc: z.object({}).strict(), } as const satisfies Record; export type BlockContentByType = { [K in BlockType]: z.infer<(typeof BLOCK_CONTENT_SCHEMAS)[K]>; }; /** * A block payload as accepted by the API (insert/update) and produced by the * editor codec. `id` is optional on insert (server assigns); `content` is * validated against the per-type schema for `type`. */ export const blockPayloadSchema = z .object({ id: z.string().min(1).optional(), type: blockTypeSchema, content: z.record(z.unknown()), properties: z.record(z.unknown()).optional(), parentId: z.string().min(1).optional(), position: z.string().min(1).optional(), }) .strict() .superRefine((val, ctx) => { const schema = BLOCK_CONTENT_SCHEMAS[val.type]; const result = schema.safeParse(val.content); if (!result.success) { for (const issue of result.error.issues) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `invalid content for block type "${val.type}": ${issue.message}`, path: ["content", ...issue.path], }); } } }); export type BlockPayload = z.infer; /** Partial patch for the `update` batch operation. */ export const blockPatchSchema = z .object({ type: blockTypeSchema.optional(), content: z.record(z.unknown()).optional(), properties: z.record(z.unknown()).optional(), position: z.string().min(1).optional(), }) .strict() .refine((v) => Object.keys(v).length > 0, { message: "empty patch" }); export type BlockPatch = z.infer; // --- batch operations --------------------------------------------------------- // NOTE: members must stay plain ZodObjects (no .refine/.superRefine) — // zod's discriminatedUnion reads `.shape` off each member directly. const insertOp = z .object({ op: z.literal("insert"), block: blockPayloadSchema, before: z.string().min(1).optional(), after: z.string().min(1).optional(), }) .strict(); const updateOp = z .object({ op: z.literal("update"), id: z.string().min(1), patch: blockPatchSchema, }) .strict(); const deleteOp = z .object({ op: z.literal("delete"), id: z.string().min(1), }) .strict(); const moveOp = z .object({ op: z.literal("move"), id: z.string().min(1), before: z.string().min(1).optional(), after: z.string().min(1).optional(), /** Cross-page move; omitted = same page. */ toPageId: z.string().min(1).optional(), }) .strict(); export const blockOperationSchema = z .discriminatedUnion("op", [insertOp, updateOp, deleteOp, moveOp]) .superRefine((val, ctx) => { if ( (val.op === "insert" || val.op === "move") && val.before !== undefined && val.after !== undefined ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "at most one of before/after", }); } }); export type BlockOperation = z.infer; /** * A batch of block operations (one API call). Max * {@link LIMITS.MAX_BLOCK_BATCH} (500) operations per call. */ export const batchBlockOperationsSchema = z .object({ operations: z .array(blockOperationSchema) .min(1, "At least one operation is required") .max(LIMITS.MAX_BLOCK_BATCH, `At most ${LIMITS.MAX_BLOCK_BATCH} operations per batch`), }) .strict(); export type BatchBlockOperations = z.infer;