Files
blog-manager/lib/validate.ts
2026-11-01 13:23:44 -05:00

104 lines
3.5 KiB
TypeScript

/* 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<string>();
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<string, unknown>) : 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<string, unknown>;
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,
};
}