muddle/core/form.ts
2026-10-23 08:53:07 -04:00

145 lines
3.9 KiB
TypeScript

import type { FormFields } from "../contracts/typegen/http/request.ts";
/** Reads the fields of a parsed form body. */
export function toFormFields(body: unknown): FormFields {
const source =
body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>)
: {};
return {
get(name: string) {
const value = source[name];
if (value === undefined || value === null) return undefined;
return Array.isArray(value) ? String(value[0]) : String(value);
},
all() {
const result: Record<string, string | string[]> = {};
for (const [key, value] of Object.entries(source)) {
result[key] = Array.isArray(value)
? value.map((entry) => String(entry))
: String(value);
}
return result;
},
has(name: string) {
return name in source;
},
values() {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
result[key] = Array.isArray(value)
? String(value[0] ?? "")
: String(value);
}
return result;
},
};
}
/** Rules of a single form field. */
export interface FieldRule {
/** The field has to be filled in. */
required?: boolean;
/** Type of the field. */
type?: "string" | "number" | "boolean" | "email";
/** Minimum value of a number field. */
min?: number;
/** Maximum value of a number field. */
max?: number;
/** Maximum length of a string field. */
maxLength?: number;
/** Allowed values. */
enum?: string[];
/** Value used when the field is empty. */
default?: unknown;
}
export type FormSchema = Record<string, FieldRule>;
/** Result of a validation. */
export type ValidationResult<T> =
| { ok: true; value: T; errors: Record<string, string> }
| { ok: false; value: undefined; errors: Record<string, string> };
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** Validates the fields of a form. */
export function validate<T extends Record<string, unknown>>(
fields: FormFields,
schema: FormSchema,
): ValidationResult<T> {
const errors: Record<string, string> = {};
const value: Record<string, unknown> = {};
for (const [name, rule] of Object.entries(schema)) {
const raw = fields.get(name);
const empty = raw === undefined || raw.trim().length === 0;
if (empty) {
if (rule.default !== undefined) {
value[name] = rule.default;
continue;
}
if (rule.required) {
errors[name] = `${name} is required`;
}
continue;
}
let parsed: unknown = raw;
if (rule.type === "number") {
const numeric = Number(raw);
if (Number.isNaN(numeric)) {
errors[name] = `${name} must be a number`;
continue;
}
if (rule.min !== undefined && numeric < rule.min) {
errors[name] = `${name} must be at least ${rule.min}`;
continue;
}
if (rule.max !== undefined && numeric > rule.max) {
errors[name] = `${name} must be at most ${rule.max}`;
continue;
}
parsed = numeric;
} else if (rule.type === "boolean") {
parsed = raw === "on" || raw === "true" || raw === "1";
} else if (rule.type === "email") {
if (!EMAIL.test(raw)) {
errors[name] = `${name} must be a valid email address`;
continue;
}
}
if (
rule.maxLength !== undefined &&
String(parsed).length > rule.maxLength
) {
errors[name] = `${name} must be at most ${rule.maxLength} characters`;
continue;
}
if (rule.enum && !rule.enum.includes(String(parsed))) {
errors[name] = `${name} must be one of ${rule.enum.join(", ")}`;
continue;
}
value[name] = parsed;
}
if (Object.keys(errors).length > 0) {
return { ok: false, value: undefined, errors };
}
return { ok: true, value: value as T, errors };
}
/**
* Builds the location of a redirect that carries a message.
*/
export function redirectWithMessage(
location: string,
message?: string,
): string {
if (!message) return location;
const separator = location.includes("?") ? "&" : "?";
return `${location}${separator}message=${encodeURIComponent(message)}`;
}