import vine from '@vinejs/vine' /** * Règles SEO + édito : * - title ≤ 60 chars (sweet spot Google snippet) * - excerpt 120-160 chars (sweet spot meta description) * - slug kebab-case ASCII unique * - tags max 5 */ const slug = () => vine .string() .minLength(3) .maxLength(200) .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) const title = () => vine.string().minLength(5).maxLength(80) const excerpt = () => vine.string().minLength(80).maxLength(280) const tags = () => vine.array(vine.string().maxLength(50)).maxLength(5).distinct() /** * URL d'image acceptée pour `hero_image_url` / `og_image_url` : * - URL absolue HTTPS (image externe — rare, mais possible) * - Path relatif commençant par /api/v1/uploads/... (uploads MinIO via * notre endpoint `BlogUploadsController.show`) * * On refuse `http://` (sécurité mixed content) et autres chemins relatifs. */ const imagePathOrUrl = () => vine .string() .maxLength(500) .regex(/^(https:\/\/.+|\/api\/v1\/uploads\/.+)$/) /** * Création d'un brouillon. On accepte `status` mais c'est toujours `draft` * à la création — la publication passe par /publish. */ export const createPostValidator = vine.compile( vine.object({ slug: slug(), title: title(), excerpt: excerpt(), contentMd: vine.string().minLength(50), tags: tags().optional(), authorName: vine.string().minLength(2).maxLength(100).optional(), heroImageUrl: imagePathOrUrl().nullable().optional(), heroImageAlt: vine.string().maxLength(250).nullable().optional(), ogImageUrl: imagePathOrUrl().nullable().optional(), canonicalUrl: vine.string().url().nullable().optional(), noindex: vine.boolean().optional(), }), ) /** * Update partiel d'un post existant. Tous les champs optionnels. * `status` n'est pas mutable ici (passer par publish/unpublish). */ export const updatePostValidator = vine.compile( vine.object({ slug: slug().optional(), title: title().optional(), excerpt: excerpt().optional(), contentMd: vine.string().minLength(50).optional(), tags: tags().optional(), authorName: vine.string().minLength(2).maxLength(100).optional(), heroImageUrl: imagePathOrUrl().nullable().optional(), heroImageAlt: vine.string().maxLength(250).nullable().optional(), ogImageUrl: imagePathOrUrl().nullable().optional(), canonicalUrl: vine.string().url().nullable().optional(), noindex: vine.boolean().optional(), }), ) /** * Au publish on durcit les règles SEO inline dans AdminPostsController.publish * (cf. PUBLISH_REQUIREMENTS) — pas un validator vine séparé car on raisonne * sur l'objet Post déjà persisté (avec wordCount calculé), pas sur un payload * de requête. */ export const PUBLISH_REQUIREMENTS = { titleMaxChars: 60, // sweet spot snippet Google excerptMinChars: 120, excerptMaxChars: 160, minWordCount: 600, // ~3 min de lecture, considéré "substantiel" } as const