/* Pure helpers shared by the editor (client) and the API (server). */ import type { GalleryImage, PostInput } from "./types"; export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; /** "Hello, World!" -> "hello-world" */ export function slugify(text: string): string { return text .toLowerCase() .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } /** Same as categoryLabel() in portfolio/data/content.ts. */ export function categoryLabel(slug: string): string { return slug .split(/[-_]+/) .filter(Boolean) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } /** Trim, drop empties, dedupe case-insensitively (first spelling wins). */ export function normalizeTags(tags: string[]): string[] { const seen = new Set(); const out: string[] = []; for (const raw of tags) { const tag = raw.trim(); const key = tag.toLowerCase(); if (!tag || seen.has(key)) continue; seen.add(key); out.push(tag); } return out; } export function todayIso(): string { const d = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } function cleanImage(raw: unknown): GalleryImage | null { const obj = typeof raw === "object" && raw !== null ? (raw as Record) : null; const src = typeof raw === "string" ? raw : obj?.src; if (typeof src !== "string") return null; const cleaned = src.trim().replace(/^\/+/, ""); if (!cleaned || cleaned.split("/").includes("..")) return null; const description = typeof obj?.description === "string" ? obj.description.trim() : ""; return description ? { src: cleaned, description } : cleaned; } /** Validates and normalizes an editor submission. */ export function validatePost(raw: unknown): { post: PostInput | null; errors: string[] } { const errors: string[] = []; const r = (raw ?? {}) as Record; const str = (key: string) => (typeof r[key] === "string" ? (r[key] as string).trim() : ""); const slug = str("slug"); const title = str("title"); const description = str("description"); const date = str("date"); const category = str("category"); const details = typeof r.details === "string" ? r.details : ""; if (!SLUG_RE.test(slug)) errors.push("slug must be lowercase kebab-case (a-z, 0-9, -)"); if (!title) errors.push("title is required"); if (!description) errors.push("description is required"); if (!DATE_RE.test(date) || Number.isNaN(Date.parse(date))) errors.push("date must be YYYY-MM-DD"); if (!SLUG_RE.test(category)) errors.push("category must be lowercase kebab-case"); if (!Array.isArray(r.tags) || r.tags.some((t) => typeof t !== "string")) { errors.push("tags must be a list of strings"); } const images: GalleryImage[] = []; if (r.images !== undefined) { if (!Array.isArray(r.images)) errors.push("images must be a list"); else for (const item of r.images) { const img = cleanImage(item); if (img) images.push(img); else errors.push("images contains an invalid path"); } } if (errors.length) return { post: null, errors }; return { post: { slug, title, description, date, category, tags: normalizeTags(r.tags as string[]), ...(images.length ? { images } : {}), details: !details || details.endsWith("\n") ? details : details + "\n", }, errors, }; }