// c31 — model: validation for the admin sxdoc edit form. Pure: no I/O. // Sibling to c31_edit_validation (markdown-editor validation), but for // the SxDocument-backed admin UI. // // Per Modeled.md: external input (HTTP form bodies) gets a parser in // c31 before any logic touches it. Handler reads FormData, hands a // Record to validateEditForm, gets back a discriminated // result the handler can react to. // Slugs may be single-segment ("about") or multi-segment ("company/about", // "docs/spec/grammar"). Each segment is lowercase a-z/0-9/-/_. Leading or // trailing slashes are trimmed by the caller before this regex runs, so // the pattern itself only matches the canonical "seg(/seg)*" shape. const SLUG_RE = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/; // 1 MiB cap on HTML body. The migration's biggest single document // (sama-meets-git-cms.md) is ~12 KB rendered — 1 MiB is generous // headroom for any realistic page, while still rejecting accidental // 50 MB pastes that would block the SQLite WAL. export const MAX_ADMIN_HTML_BYTES = 1024 * 1024; export interface ValidatedEditInput { slug: string; type: "page" | "post"; title: string; html: string; status: "published" | "draft"; primaryTag: string | null; } export type AdminValidationResult = | { ok: true; data: ValidatedEditInput } | { ok: false; error: string }; export const validateEditForm = (form: Record): AdminValidationResult => { const slug = (form.slug ?? "").trim().toLowerCase().replace(/^\/+|\/+$/g, ""); const type = form.type ?? ""; const title = (form.title ?? "").trim(); const html = form.html ?? ""; const statusRaw = form.status ?? "published"; const primaryTag = (form.primary_tag ?? "").trim() || null; if (!title) return { ok: false, error: "title is required" }; if (!SLUG_RE.test(slug)) { return { ok: false, error: "slug must be lowercase segments (letters, digits, dash, underscore) joined by single slashes — e.g. about, company/about, docs/spec/grammar", }; } if (type !== "page" && type !== "post") { return { ok: false, error: "type must be page or post" }; } if (statusRaw !== "published" && statusRaw !== "draft") { return { ok: false, error: "status must be published or draft" }; } const bytes = new TextEncoder().encode(html).length; if (bytes > MAX_ADMIN_HTML_BYTES) { return { ok: false, error: `body exceeds the ${MAX_ADMIN_HTML_BYTES / 1024} KB limit (got ${Math.round(bytes / 1024)} KB)`, }; } return { ok: true, data: { slug, type, title, html, status: statusRaw, primaryTag }, }; };