import { z } from 'zod' // Same convention as validation/eventCategories.ts — slugs are URL/API // identifiers, always Latin, no Persian-digit conversion. const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/ export const BLOG_ARTICLE_CATEGORIES = [ { code: 'attendee-guide', name: 'راهنمای شرکت‌کنندگان' }, { code: 'hosting', name: 'راهنمای میزبانی' }, { code: 'city-guides', name: 'راهنمای شهرها' }, { code: 'event-selection', name: 'انتخاب رویداد' }, ] as const export const analyzeArticleBody = (bodyHtml: string) => { const plainText = bodyHtml .replace(/<[^>]*>/g, ' ') .replace(/\s+/g, ' ') .trim() const wordCount = plainText ? plainText.split(' ').length : 0 const headingCount = (bodyHtml.match(/]*)?>/gi) ?? []).length const internalLinkCount = (bodyHtml.match(/]*href=["']\/(?!\/)[^"']*["']/gi) ?? []).length const images = bodyHtml.match(/]*>/gi) ?? [] const imagesWithoutAlt = images.filter((tag) => !/\salt=["'][^"']+["']/i.test(tag)).length return { wordCount, headingCount, internalLinkCount, imagesWithoutAlt } } // SEO/char-limited field limits — also referenced by ArticleFormModal's // SeoCharCounterHint call sites, so the UI hint and the zod schema can // never drift apart. export const ARTICLE_TITLE_MAX = 52 export const ARTICLE_EXCERPT_MIN = 70 export const ARTICLE_EXCERPT_MAX = 160 export const ARTICLE_META_TITLE_MAX = 52 export const ARTICLE_META_DESCRIPTION_MIN = 70 export const ARTICLE_META_DESCRIPTION_MAX = 160 export const ArticleFormValidation = z .object({ slug: z .string() .trim() .min(1, 'اسلاگ را وارد کنید') .max(160, 'اسلاگ حداکثر ۱۶۰ کاراکتر است') .regex(SLUG_PATTERN, 'اسلاگ فقط می‌تواند شامل حروف انگلیسی کوچک، عدد و خط تیره باشد'), title: z .string() .trim() .min(1, 'عنوان را وارد کنید') .max(ARTICLE_TITLE_MAX, 'عنوان حداکثر ۵۲ کاراکتر است تا با پسوند سایت زیر ۶۰ کاراکتر بماند'), excerpt: z .string() .trim() .min(ARTICLE_EXCERPT_MIN, 'برای استفاده‌ی کامل در نتایج گوگل، توضیح باید حداقل ۷۰ کاراکتر باشد') .max(ARTICLE_EXCERPT_MAX, 'توضیح حداکثر ۱۶۰ کاراکتر است'), metaTitle: z .string() .trim() .max(ARTICLE_META_TITLE_MAX, 'عنوان SEO با احتساب پسوند برند باید حداکثر ۵۲ کاراکتر باشد') .optional() .or(z.literal('')), metaDescription: z .string() .trim() .min(ARTICLE_META_DESCRIPTION_MIN, 'توضیحات SEO باید حداقل ۷۰ کاراکتر باشد') .max(ARTICLE_META_DESCRIPTION_MAX, 'توضیحات SEO حداکثر ۱۶۰ کاراکتر است') .optional() .or(z.literal('')), categorySlug: z.enum(['attendee-guide', 'hosting', 'city-guides', 'event-selection'], { message: 'یک دسته‌بندی انتخاب کنید', }), categoryName: z.string().trim().min(1), // The Select control (ChoiceFieldControl) only ever writes back strings, // even for numeric ids — kept as string here and converted to // number | undefined right before the API call in ArticleFormModal. cityId: z.string().optional().or(z.literal('')), eventCategoryId: z.string().optional().or(z.literal('')), bodyHtml: z.string().trim().min(1, 'متن مقاله را وارد کنید'), featuredImageUrl: z.union([z.literal(''), z.url('آدرس تصویر معتبر نیست')]).optional(), isFeatured: z.boolean(), isPublished: z.boolean(), scheduledDate: z.string().optional().or(z.literal('')), scheduledTime: z.number().min(0).max(1439), }) .superRefine((values, ctx) => { if (!values.scheduledDate || values.isPublished) return const scheduledAt = new Date(values.scheduledDate) scheduledAt.setHours(Math.floor(values.scheduledTime / 60), values.scheduledTime % 60, 0, 0) if (!Number.isFinite(scheduledAt.getTime()) || scheduledAt.getTime() <= Date.now()) { ctx.addIssue({ code: 'custom', message: 'زمان انتشار باید در آینده باشد', path: ['scheduledDate'], }) } }) export type ArticleFormValues = z.infer