import { z } from "zod"; import { fontEnum, themeEnum } from "@/db/schema"; /** http(s) URL or a site-relative path starting with a single "/". */ export function isValidLinkUrl(value: string): boolean { if (value.startsWith("/")) return !value.startsWith("//"); try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } const slugInput = z .string() .trim() .max(120, "Slug must be at most 120 characters.") .default(""); const statusInput = z.enum(["draft", "published"], { message: "Status must be draft or published.", }); export const postFormSchema = z.object({ title: z.string().trim().min(1, "Title is required.").max(200, "Title is too long."), slug: slugInput, authorName: z .string() .trim() .min(1, "Author name is required.") .max(120, "Author name is too long."), body: z.string().max(500_000, "Body is too long."), featuredImageUrl: z .string() .trim() .max(2000, "Image URL is too long.") .default("") .refine((v) => v === "" || isValidLinkUrl(v), { message: "Enter an http(s) URL or a site-relative path.", }), featuredImageAlt: z.string().trim().max(300, "Alt text is too long.").default(""), status: statusInput, tagIds: z.array(z.coerce.number().int().positive()).max(50).default([]), newTags: z.string().trim().max(500, "New tag list is too long.").default(""), }); export type PostFormData = z.infer; export const pageFormSchema = z.object({ title: z.string().trim().min(1, "Title is required.").max(200, "Title is too long."), slug: slugInput, body: z.string().max(500_000, "Body is too long."), status: statusInput, }); export type PageFormData = z.infer; export const commentFormSchema = z.object({ postId: z.coerce.number().int().positive(), parentId: z.preprocess( (v) => (v === "" || v === null || v === undefined ? null : v), z.coerce.number().int().positive().nullable(), ), authorName: z.string().trim().min(1, "Name is required.").max(120, "Name is too long."), authorEmail: z .string() .trim() .min(1, "Email is required.") .max(254, "Email is too long.") .pipe(z.email("Enter a valid email address.")), // Checkbox: present ("on") when ticked, absent otherwise. emailPublic: z.preprocess((v) => v === "on" || v === "true" || v === true, z.boolean()), body: z .string() .trim() .min(1, "Comment cannot be empty.") .max(5000, "Comments are limited to 5000 characters."), }); export type CommentFormData = z.infer; export const loginFormSchema = z.object({ username: z.string().trim().min(1, "Username is required.").max(120), password: z.string().min(1, "Password is required.").max(200), }); const usernameInput = z .string() .trim() .min(1, "Username is required.") .max(120, "Username is too long.") .regex( /^[a-zA-Z0-9._-]+$/, "Usernames may only contain letters, numbers, dots, dashes, and underscores.", ); const newPasswordInput = z .string() .min(8, "Password must be at least 8 characters.") .max(200, "Password is too long."); /** Checkbox: present ("on") when ticked, absent otherwise. */ const checkboxInput = z.preprocess((v) => v === "on" || v === "true" || v === true, z.boolean()); const permissionsInput = z.object({ canCreateTags: checkboxInput, canPublishPosts: checkboxInput, canUnpublishPosts: checkboxInput, canDeletePosts: checkboxInput, canApproveComments: checkboxInput, }); export const createUserFormSchema = z.object({ username: usernameInput, password: newPasswordInput, tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]), permissions: permissionsInput, }); export const updateUserFormSchema = z.object({ // Blank means "keep the current password". password: z.preprocess( (v) => (v === "" || v === null ? null : v), newPasswordInput.nullable(), ), tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]), permissions: permissionsInput, }); export const changePasswordFormSchema = z.object({ currentPassword: z.string().min(1, "Enter your current password.").max(200), newPassword: newPasswordInput, }); export const navItemSchema = z .object({ label: z.string().trim().min(1, "Every navigation item needs a label.").max(80), url: z.string().trim().max(2000).nullable(), pageId: z.number().int().positive().nullable(), }) .superRefine((item, ctx) => { const hasUrl = item.url !== null && item.url !== ""; const hasPage = item.pageId !== null; if (hasUrl === hasPage) { ctx.addIssue({ code: "custom", message: `“${item.label}” must link to either a page or a URL.`, }); } else if (hasUrl && !isValidLinkUrl(item.url as string)) { ctx.addIssue({ code: "custom", message: `“${item.label}” needs an http(s) URL or a path starting with “/”.`, }); } }); const optionalId = z.preprocess( (v) => (v === "" || v === null || v === undefined ? undefined : v), z.coerce.number().int().positive().optional(), ); export const settingsFormSchema = z.object({ siteTitle: z.string().trim().min(1, "Site title is required.").max(120), headerText: z.string().trim().max(300, "Header text is too long.").default(""), footerText: z.string().trim().max(500, "Footer text is too long.").default(""), postsPerPage: z.coerce .number({ message: "Posts per page must be a number." }) .int("Posts per page must be a whole number.") .min(1, "Posts per page must be at least 1.") .max(50, "Posts per page must be at most 50."), excerptWords: z.coerce .number({ message: "Excerpt word limit must be a number." }) .int("Excerpt word limit must be a whole number.") .min(5, "Excerpt word limit must be at least 5.") .max(200, "Excerpt word limit must be at most 200."), homeMode: z.enum(["posts", "tag", "page"]), homeTagId: optionalId, homePageId: optionalId, theme: z.enum(themeEnum.enumValues, { message: "Choose one of the available themes.", }), font: z.enum(fontEnum.enumValues, { message: "Choose one of the available fonts.", }), navItemsJson: z.string().max(50_000).default("[]"), }); export type SettingsFormData = z.infer; export function parseNavItemsJson( json: string, ): { items: Array> } | { error: string } { let raw: unknown; try { raw = JSON.parse(json); } catch { return { error: "Navigation items could not be read. Reload and try again." }; } const parsed = z.array(navItemSchema).max(20, "At most 20 navigation items.").safeParse(raw); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? "Navigation items are invalid." }; } return { items: parsed.data }; }