1
10
11import { z } from "zod";
12
13import { LIMITS } from "../constants.js";
14
15
19export const blockTypeSchema = z.enum([
20 "paragraph",
21 "heading_1",
22 "heading_2",
23 "heading_3",
24 "quote",
25 "callout",
26 "bulleted_list",
27 "numbered_list",
28 "todo",
29 "toggle",
30 "image",
31 "video",
32 "audio",
33 "file",
34 "bookmark",
35 "embed",
36 "code",
37 "equation",
38 "column",
39 "column_list",
40 "divider",
41 "spacer",
42 "database",
43 "linked_database",
44 "table",
45 "chart",
46 "form",
47 "synced_block",
48 "child_page",
49 "child_database",
50
51
52
53
54 "toc",
55]);
56export type BlockType = z.infer<typeof blockTypeSchema>;
57
58
59export const CALLOUT_VARIANTS = ["info", "warning", "tip", "error"] as const;
60export type CalloutVariant = (typeof CALLOUT_VARIANTS)[number];
61
62const blockText = z.string().max(LIMITS.MAX_BLOCK_TEXT, "Block text is too long");
63const fileId = z.string().min(1);
64const url = z.string().min(1).url();
65
66
67
68const textContent = z.object({ text: blockText }).strict();
69
70
71
72
73const calloutContent = z
74 .object({
75 text: blockText,
76 variant: z.enum(CALLOUT_VARIANTS).optional(),
77 })
78 .strict();
79
80const codeContent = z
81 .object({
82 text: blockText,
83 language: z.string().min(1).max(32).optional(),
84 })
85 .strict();
86
87const dimensionContent = z
88 .object({
89 height: z.number().int().positive().max(1000).optional(),
90 })
91 .strict();
92
93const mediaContent = z
94 .object({
95 fileId,
96 caption: z.string().max(500).optional(),
97 width: z.number().int().positive().max(4096).optional(),
98 })
99 .strict();
100
101const bookmarkContent = z
102 .object({
103 url,
104 title: z.string().max(300).optional(),
105 })
106 .strict();
107
108const tableContent = z
109 .object({
110 rows: z
111 .array(z.array(z.string().max(LIMITS.MAX_BLOCK_TEXT)))
112 .min(1)
113 .max(1000),
114 width: z.number().int().positive().max(4096).optional(),
115 })
116 .strict();
117
118const databaseContent = z.object({ databaseId: fileId }).strict();
119const linkedDatabaseContent = z
120 .object({
121 databaseId: fileId,
122 viewId: fileId.optional(),
123 })
124 .strict();
125
126const columnContent = z
127 .object({
128 width: z.number().int().positive().max(4096).optional(),
129 })
130 .strict();
131
132const columnListContent = z
133 .object({
134 direction: z.enum(["row", "column"]).default("row"),
135 })
136 .strict();
137
138
142export const BLOCK_CONTENT_SCHEMAS = {
143 paragraph: textContent,
144 heading_1: textContent,
145 heading_2: textContent,
146 heading_3: textContent,
147 quote: textContent,
148 callout: calloutContent,
149 bulleted_list: textContent,
150 numbered_list: textContent,
151 todo: textContent,
152 toggle: textContent,
153 image: mediaContent,
154 video: mediaContent,
155 audio: mediaContent,
156 file: mediaContent,
157 bookmark: bookmarkContent,
158 embed: z.object({ url }).strict(),
159 code: codeContent,
160 equation: z.object({ text: blockText }).strict(),
161 column: columnContent,
162 column_list: columnListContent,
163 divider: dimensionContent,
164 spacer: dimensionContent,
165 database: databaseContent,
166 linked_database: linkedDatabaseContent,
167 table: tableContent,
168 chart: z.object({ chartId: fileId }).strict(),
169 form: z.object({ formId: fileId }).strict(),
170 synced_block: z.object({ sourceBlockId: fileId }).strict(),
171 child_page: z.object({ pageId: fileId }).strict(),
172 child_database: databaseContent,
173
174 toc: z.object({}).strict(),
175} as const satisfies Record<BlockType, z.ZodTypeAny>;
176
177export type BlockContentByType = {
178 [K in BlockType]: z.infer<(typeof BLOCK_CONTENT_SCHEMAS)[K]>;
179};
180
181
186export const blockPayloadSchema = z
187 .object({
188 id: z.string().min(1).optional(),
189 type: blockTypeSchema,
190 content: z.record(z.unknown()),
191 properties: z.record(z.unknown()).optional(),
192 parentId: z.string().min(1).optional(),
193 position: z.string().min(1).optional(),
194 })
195 .strict()
196 .superRefine((val, ctx) => {
197 const schema = BLOCK_CONTENT_SCHEMAS[val.type];
198 const result = schema.safeParse(val.content);
199 if (!result.success) {
200 for (const issue of result.error.issues) {
201 ctx.addIssue({
202 code: z.ZodIssueCode.custom,
203 message: `invalid content for block type "${val.type}": ${issue.message}`,
204 path: ["content", ...issue.path],
205 });
206 }
207 }
208 });
209export type BlockPayload = z.infer<typeof blockPayloadSchema>;
210
211
212export const blockPatchSchema = z
213 .object({
214 type: blockTypeSchema.optional(),
215 content: z.record(z.unknown()).optional(),
216 properties: z.record(z.unknown()).optional(),
217 position: z.string().min(1).optional(),
218 })
219 .strict()
220 .refine((v) => Object.keys(v).length > 0, { message: "empty patch" });
221export type BlockPatch = z.infer<typeof blockPatchSchema>;
222
223
224
225
226
227const insertOp = z
228 .object({
229 op: z.literal("insert"),
230 block: blockPayloadSchema,
231 before: z.string().min(1).optional(),
232 after: z.string().min(1).optional(),
233 })
234 .strict();
235
236const updateOp = z
237 .object({
238 op: z.literal("update"),
239 id: z.string().min(1),
240 patch: blockPatchSchema,
241 })
242 .strict();
243
244const deleteOp = z
245 .object({
246 op: z.literal("delete"),
247 id: z.string().min(1),
248 })
249 .strict();
250
251const moveOp = z
252 .object({
253 op: z.literal("move"),
254 id: z.string().min(1),
255 before: z.string().min(1).optional(),
256 after: z.string().min(1).optional(),
257
258 toPageId: z.string().min(1).optional(),
259 })
260 .strict();
261
262export const blockOperationSchema = z
263 .discriminatedUnion("op", [insertOp, updateOp, deleteOp, moveOp])
264 .superRefine((val, ctx) => {
265 if (
266 (val.op === "insert" || val.op === "move") &&
267 val.before !== undefined &&
268 val.after !== undefined
269 ) {
270 ctx.addIssue({
271 code: z.ZodIssueCode.custom,
272 message: "at most one of before/after",
273 });
274 }
275 });
276export type BlockOperation = z.infer<typeof blockOperationSchema>;
277
278
282export const batchBlockOperationsSchema = z
283 .object({
284 operations: z
285 .array(blockOperationSchema)
286 .min(1, "At least one operation is required")
287 .max(LIMITS.MAX_BLOCK_BATCH, `At most ${LIMITS.MAX_BLOCK_BATCH} operations per batch`),
288 })
289 .strict();
290export type BatchBlockOperations = z.infer<typeof batchBlockOperationsSchema>;
291