104 lines
3.5 KiB
TypeScript
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,
|
|
};
|
|
}
|