first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
---
/**
* Standalone form component.
*
* Use this outside Portable Text content to embed a form directly.
*
* @example
* ```astro
* ---
* import { Form } from "@emdashcms/plugin-forms/ui";
* ---
*
* <Form id="contact-form" />
* ```
*/
import FormEmbed from "./FormEmbed.astro";
interface Props {
/** Form ID or slug */
id: string;
}
const { id } = Astro.props;
---
<FormEmbed node={{ formId: id }} />

View File

@@ -0,0 +1,301 @@
---
/**
* Form embed component for Portable Text blocks.
*
* Server-renders the full form with all pages as <fieldset> elements.
* Without JavaScript, all pages are visible as one long form.
* The client-side script enhances with multi-page navigation, AJAX, etc.
*/
interface Props {
node: { formId: string };
}
interface FormField {
id: string;
type: string;
label: string;
name: string;
placeholder?: string;
helpText?: string;
required: boolean;
validation?: {
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
pattern?: string;
patternMessage?: string;
accept?: string;
maxFileSize?: number;
};
options?: Array<{ label: string; value: string }>;
defaultValue?: string;
width: "full" | "half";
condition?: { field: string; op: string; value?: string };
}
interface FormPage {
title?: string;
fields: FormField[];
}
interface FormDefinition {
name: string;
slug: string;
pages: FormPage[];
settings: {
spamProtection: string;
submitLabel: string;
nextLabel?: string;
prevLabel?: string;
};
status: string;
_turnstileSiteKey?: string | null;
}
const { node } = Astro.props;
const formId = node.formId;
// Fetch form definition server-side
const response = await fetch(
new URL("/_emdash/api/plugins/emdash-forms/definition", Astro.url),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: formId }),
}
);
if (!response.ok) return;
const form = (await response.json()) as FormDefinition;
if (!form || form.status !== "active") return;
const submitUrl = `/_emdash/api/plugins/emdash-forms/submit`;
const isMultiPage = form.pages.length > 1;
const turnstileSiteKey = form._turnstileSiteKey;
const hasFiles = form.pages.some((p: FormPage) =>
p.fields.some((f: FormField) => f.type === "file")
);
/** Generate an element ID for a field */
function fieldId(name: string): string {
return `${formId}-${name}`;
}
---
<form
class="ec-form"
method="POST"
action={submitUrl}
enctype={hasFiles ? "multipart/form-data" : undefined}
data-form-id={formId}
data-ec-form
data-pages={isMultiPage ? form.pages.length : undefined}
>
{
form.pages.map((page: FormPage, pageIndex: number) => (
<fieldset
class="ec-form-page"
data-page={pageIndex}
aria-label={page.title || `Page ${pageIndex + 1}`}
>
{isMultiPage && page.title && (
<legend class="ec-form-page-title">{page.title}</legend>
)}
{page.fields.map((field: FormField) => (
<div
class:list={[
"ec-form-field",
`ec-form-field--${field.type}`,
field.width === "half" && "ec-form-field--half",
]}
data-condition={
field.condition ? JSON.stringify(field.condition) : undefined
}
>
{field.type !== "hidden" && field.type !== "checkbox" && (
<label class="ec-form-label" for={fieldId(field.name)}>
{field.label}
{field.required && (
<span class="ec-form-required" aria-label="required">
*
</span>
)}
</label>
)}
{[
"text",
"email",
"tel",
"url",
"number",
"date",
"hidden",
].includes(field.type) && (
<input
type={field.type as astroHTML.JSX.HTMLInputTypeAttribute}
class={field.type !== "hidden" ? "ec-form-input" : undefined}
id={fieldId(field.name)}
name={field.name}
placeholder={field.placeholder}
required={field.required}
minlength={field.validation?.minLength}
maxlength={field.validation?.maxLength}
min={field.validation?.min}
max={field.validation?.max}
pattern={field.validation?.pattern}
value={field.defaultValue}
/>
)}
{field.type === "file" && (
<input
type="file"
class="ec-form-input"
id={fieldId(field.name)}
name={field.name}
required={field.required}
accept={field.validation?.accept}
/>
)}
{field.type === "textarea" && (
<textarea
class="ec-form-input"
id={fieldId(field.name)}
name={field.name}
placeholder={field.placeholder}
required={field.required}
minlength={field.validation?.minLength}
maxlength={field.validation?.maxLength}
>
{field.defaultValue || ""}
</textarea>
)}
{field.type === "select" && (
<select
class="ec-form-input"
id={fieldId(field.name)}
name={field.name}
required={field.required}
>
{(field.options || []).map((o) => (
<option
value={o.value}
selected={o.value === field.defaultValue}
>
{o.label}
</option>
))}
</select>
)}
{field.type === "radio" && (
<fieldset class="ec-form-radio-group" role="radiogroup">
{(field.options || []).map((o) => (
<label class="ec-form-radio-label">
<input
type="radio"
name={field.name}
value={o.value}
checked={o.value === field.defaultValue}
required={field.required}
/>{" "}
{o.label}
</label>
))}
</fieldset>
)}
{field.type === "checkbox" && (
<label class="ec-form-checkbox-label">
<input
type="checkbox"
class="ec-form-input"
id={fieldId(field.name)}
name={field.name}
value={field.defaultValue || "1"}
required={field.required}
/>{" "}
{field.label}
</label>
)}
{field.type === "checkbox-group" && (
<fieldset class="ec-form-checkbox-group">
{(field.options || []).map((o) => (
<label class="ec-form-checkbox-label">
<input type="checkbox" name={field.name} value={o.value} />{" "}
{o.label}
</label>
))}
</fieldset>
)}
{field.helpText && (
<span class="ec-form-help">{field.helpText}</span>
)}
<span
class="ec-form-error"
data-error-for={field.name}
aria-live="polite"
/>
</div>
))}
</fieldset>
))
}
{
form.settings.spamProtection === "honeypot" && (
<div
class="ec-form-field"
style="position:absolute;left:-9999px;"
aria-hidden="true"
>
<label for={`${formId}-_hp`}>Leave blank</label>
<input
type="text"
id={`${formId}-_hp`}
name="_hp"
tabindex="-1"
autocomplete="off"
/>
</div>
)
}
{
form.settings.spamProtection === "turnstile" && turnstileSiteKey && (
<div
class="ec-form-turnstile"
data-ec-turnstile
data-sitekey={turnstileSiteKey}
/>
)
}
<input type="hidden" name="formId" value={formId} />
<div class="ec-form-nav">
<button type="button" class="ec-form-prev" data-ec-prev hidden>
{form.settings.prevLabel || "Previous"}
</button>
<button type="button" class="ec-form-next" data-ec-next hidden>
{form.settings.nextLabel || "Next"}
</button>
<button type="submit" class="ec-form-submit">
{form.settings.submitLabel || "Submit"}
</button>
</div>
{
isMultiPage && (
<div class="ec-form-progress" data-ec-progress aria-live="polite" />
)
}
<div class="ec-form-status" data-form-status aria-live="polite"></div>
</form>
<script>
import { initForms } from "@emdashcms/plugin-forms/client";
initForms();
</script>

View File

@@ -0,0 +1,11 @@
/**
* Astro component exports for the forms plugin.
*
* Auto-wired via the `virtual:emdash/block-components` virtual module.
*/
import FormEmbed from "./FormEmbed.astro";
export const blockComponents = {
"emdash-form": FormEmbed,
};

View File

@@ -0,0 +1,536 @@
/**
* Client-side form enhancement.
*
* Following the same progressive enhancement pattern as Astro's <ClientRouter />,
* this uses event delegation on `document` — a single set of listeners handles
* all forms on the page, including forms added after initial load.
*
* Features:
* - AJAX submission (no page reload)
* - Client-side validation with inline errors
* - Multi-page navigation with history integration
* - Conditional field visibility
* - Session persistence (survives page refreshes)
* - Turnstile widget injection
* - File upload with FormData
*/
const STORAGE_PREFIX = "ec-form:";
const DEBOUNCE_MS = 500;
let saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
let listenersRegistered = false;
// ─── Initialization ──────────────────────────────────────────────
export function initForms() {
const init = () => {
document.querySelectorAll<HTMLFormElement>("[data-ec-form]").forEach((form) => {
if (form.dataset.ecInitialized) return;
form.dataset.ecInitialized = "1";
restoreState(form);
initMultiPage(form);
initConditions(form);
initTurnstile(form);
});
};
// Guard against duplicate listener registration
if (!listenersRegistered) {
listenersRegistered = true;
// Event delegation — handles all forms, current and future
document.addEventListener("submit", handleSubmit);
document.addEventListener("click", handleClick);
document.addEventListener("input", handleInput);
document.addEventListener("change", handleChange);
window.addEventListener("popstate", handlePopState);
// Astro ClientRouter fires astro:page-load on every navigation
document.addEventListener("astro:page-load", init);
// Clean up pending save timers before view transitions swap the DOM
document.addEventListener("astro:before-swap", () => {
for (const timer of saveTimers.values()) {
clearTimeout(timer);
}
saveTimers.clear();
});
}
// Fallback for sites without ClientRouter
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
}
// ─── Submit Handler ──────────────────────────────────────────────
async function handleSubmit(e: Event) {
const form = (e.target as HTMLElement).closest<HTMLFormElement>("[data-ec-form]");
if (!form) return;
e.preventDefault();
// Validate current (or last) page
if (!validateVisibleFields(form)) return;
const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = "Submitting...";
}
clearStatus(form);
try {
const hasFiles = form.querySelector<HTMLInputElement>('input[type="file"]');
let body: BodyInit;
const headers: Record<string, string> = {};
if (hasFiles) {
body = new FormData(form);
} else {
headers["Content-Type"] = "application/json";
const formData = new FormData(form);
let formId = "";
const data: Record<string, unknown> = {};
// Track keys we've seen to detect multi-value fields (checkbox-group)
const seen = new Set<string>();
for (const [key, val] of formData) {
if (typeof val !== "string") continue;
if (key === "formId") {
formId = val;
} else if (key === "_hp" || key === "cf-turnstile-response") {
// Include spam fields at top level for server-side checks
data[key] = val;
} else if (seen.has(key)) {
// Multi-value field (checkbox-group) — collect into array
const existing = data[key];
if (Array.isArray(existing)) {
existing.push(val);
} else {
data[key] = [existing, val];
}
} else {
seen.add(key);
data[key] = val;
}
}
body = JSON.stringify({ formId, data });
}
const res = await fetch(form.action, {
method: "POST",
headers,
body,
});
const result = (await res.json()) as {
success?: boolean;
message?: string;
redirect?: string;
errors?: Array<{ field: string; message: string }>;
};
if (result.success) {
clearSavedState(form);
if (result.redirect) {
window.location.href = result.redirect;
} else {
showStatus(form, result.message || "Submitted successfully.", "success");
form.reset();
}
} else if (result.errors) {
showErrors(form, result.errors);
} else {
showStatus(form, "Something went wrong. Please try again.", "error");
}
} catch {
showStatus(form, "Network error. Please try again.", "error");
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = form.dataset.submitLabel || "Submit";
}
}
}
// ─── Click Handler (Prev/Next) ───────────────────────────────────
function handleClick(e: Event) {
const target = e.target as HTMLElement;
const nextBtn = target.closest("[data-ec-next]");
if (nextBtn) {
const form = nextBtn.closest<HTMLFormElement>("[data-ec-form]");
if (form) {
const current = getCurrentPage(form);
if (validatePage(form, current)) {
showPage(form, current + 1);
saveState(form);
history.pushState({ ecFormPage: current + 1, ecFormId: form.dataset.formId }, "");
}
}
return;
}
const prevBtn = target.closest("[data-ec-prev]");
if (prevBtn) {
const form = prevBtn.closest<HTMLFormElement>("[data-ec-form]");
if (form) {
const current = getCurrentPage(form);
if (current > 0) {
showPage(form, current - 1);
saveState(form);
history.pushState({ ecFormPage: current - 1, ecFormId: form.dataset.formId }, "");
}
}
}
}
// ─── Input/Change Handlers ───────────────────────────────────────
function handleInput(e: Event) {
const target = e.target as HTMLElement;
const form = target.closest<HTMLFormElement>("[data-ec-form]");
if (!form) return;
// Clear field error on input
const name = (target as HTMLInputElement).name;
if (name) {
const errorEl = form.querySelector(`[data-error-for="${name}"]`);
if (errorEl) errorEl.textContent = "";
}
// Debounced save
debouncedSave(form);
}
function handleChange(e: Event) {
const target = e.target as HTMLElement;
const form = target.closest<HTMLFormElement>("[data-ec-form]");
if (!form) return;
// Evaluate conditions
evaluateConditions(form);
}
// ─── Popstate Handler ────────────────────────────────────────────
function handlePopState(e: PopStateEvent) {
if (e.state && typeof e.state.ecFormPage === "number" && typeof e.state.ecFormId === "string") {
const form = document.querySelector<HTMLFormElement>(
`[data-ec-form][data-form-id="${CSS.escape(e.state.ecFormId)}"]`,
);
if (form) {
const pages = form.querySelectorAll("[data-page]");
const page = Math.min(e.state.ecFormPage, pages.length - 1);
showPage(form, Math.max(0, page));
}
}
}
// ─── Multi-Page ──────────────────────────────────────────────────
function initMultiPage(form: HTMLFormElement) {
const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
if (pages.length <= 1) return;
// Hide all pages except first
pages.forEach((page, i) => {
if (i > 0) {
page.hidden = true;
// Remove required from hidden pages to prevent native validation
page.querySelectorAll<HTMLElement>("[required]").forEach((el) => {
el.removeAttribute("required");
el.dataset.wasRequired = "1";
});
}
});
// Show next button, hide submit (unless single page)
const nextBtn = form.querySelector<HTMLButtonElement>("[data-ec-next]");
const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
if (nextBtn) nextBtn.hidden = false;
if (submitBtn) submitBtn.hidden = true;
updateProgress(form, 0, pages.length);
}
function showPage(form: HTMLFormElement, pageIndex: number) {
const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
const totalPages = pages.length;
pages.forEach((page, i) => {
if (i === pageIndex) {
page.hidden = false;
// Restore required attributes
page.querySelectorAll<HTMLElement>("[data-was-required]").forEach((el) => {
el.setAttribute("required", "");
delete el.dataset.wasRequired;
});
} else {
page.hidden = true;
// Strip required from hidden
page.querySelectorAll<HTMLElement>("[required]").forEach((el) => {
el.removeAttribute("required");
el.dataset.wasRequired = "1";
});
}
});
// Update button visibility
const prevBtn = form.querySelector<HTMLButtonElement>("[data-ec-prev]");
const nextBtn = form.querySelector<HTMLButtonElement>("[data-ec-next]");
const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
if (prevBtn) prevBtn.hidden = pageIndex === 0;
if (nextBtn) nextBtn.hidden = pageIndex === totalPages - 1;
if (submitBtn) submitBtn.hidden = pageIndex < totalPages - 1;
updateProgress(form, pageIndex, totalPages);
}
function getCurrentPage(form: HTMLFormElement): number {
const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
for (let i = 0; i < pages.length; i++) {
if (!pages[i]!.hidden) return i;
}
return 0;
}
function updateProgress(form: HTMLFormElement, current: number, total: number) {
const progress = form.querySelector("[data-ec-progress]");
if (progress) {
progress.textContent = `Step ${current + 1} of ${total}`;
}
}
// ─── Validation ──────────────────────────────────────────────────
function validatePage(form: HTMLFormElement, pageIndex: number): boolean {
const page = form.querySelector<HTMLFieldSetElement>(`[data-page="${pageIndex}"]`);
if (!page) return true;
let valid = true;
page
.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
"input, select, textarea",
)
.forEach((input) => {
if (!input.checkValidity()) {
valid = false;
showFieldError(form, input.name, input.validationMessage);
}
});
return valid;
}
function validateVisibleFields(form: HTMLFormElement): boolean {
let valid = true;
form.querySelectorAll<HTMLFieldSetElement>("[data-page]:not([hidden])").forEach((page) => {
page
.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
"input, select, textarea",
)
.forEach((input) => {
if (!input.checkValidity()) {
valid = false;
showFieldError(form, input.name, input.validationMessage);
}
});
});
return valid;
}
function showFieldError(form: HTMLFormElement, fieldName: string, message: string) {
const errorEl = form.querySelector(`[data-error-for="${fieldName}"]`);
if (errorEl) errorEl.textContent = message;
}
function showErrors(form: HTMLFormElement, errors: Array<{ field: string; message: string }>) {
for (const err of errors) {
showFieldError(form, err.field, err.message);
}
}
// ─── Status Messages ─────────────────────────────────────────────
function showStatus(form: HTMLFormElement, message: string, type: "success" | "error") {
const status = form.querySelector("[data-form-status]");
if (status) {
status.textContent = message;
status.className = `ec-form-status ec-form-status--${type}`;
}
}
function clearStatus(form: HTMLFormElement) {
const status = form.querySelector("[data-form-status]");
if (status) {
status.textContent = "";
status.className = "ec-form-status";
}
}
// ─── Conditional Fields ──────────────────────────────────────────
function initConditions(form: HTMLFormElement) {
evaluateConditions(form);
}
function evaluateConditions(form: HTMLFormElement) {
form.querySelectorAll<HTMLElement>("[data-condition]").forEach((wrapper) => {
try {
const condition = JSON.parse(wrapper.dataset.condition || "{}") as {
field: string;
op: string;
value?: string;
};
const input = form.elements.namedItem(condition.field) as HTMLInputElement | null;
if (!input) return;
const value = input.value;
let visible = true;
switch (condition.op) {
case "eq":
visible = value === (condition.value ?? "");
break;
case "neq":
visible = value !== (condition.value ?? "");
break;
case "filled":
visible = value !== "";
break;
case "empty":
visible = value === "";
break;
}
wrapper.hidden = !visible;
// Disable inputs in hidden fields so they're excluded from FormData
wrapper.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((el) => {
el.disabled = !visible;
});
} catch {
// Invalid condition JSON — show field
}
});
}
// ─── Session Persistence ─────────────────────────────────────────
function saveState(form: HTMLFormElement) {
const formId = form.dataset.formId;
if (!formId) return;
const page = getCurrentPage(form);
const values: Record<string, string> = {};
for (const [key, val] of new FormData(form)) {
if (typeof val === "string") values[key] = val;
}
try {
sessionStorage.setItem(
STORAGE_PREFIX + formId,
JSON.stringify({ page, values, savedAt: Date.now() }),
);
} catch {
// sessionStorage full or unavailable — ignore
}
}
function restoreState(form: HTMLFormElement) {
const formId = form.dataset.formId;
if (!formId) return;
try {
const raw = sessionStorage.getItem(STORAGE_PREFIX + formId);
if (!raw) return;
const state = JSON.parse(raw) as {
page: number;
values: Record<string, string>;
};
// Restore field values
for (const [name, value] of Object.entries(state.values)) {
const input = form.elements.namedItem(name);
if (input && "value" in input) {
(input as unknown as HTMLInputElement).value = value;
}
}
// Navigate to saved page (clamped to valid range)
if (state.page > 0) {
const pages = form.querySelectorAll("[data-page]");
const page = Math.min(state.page, pages.length - 1);
if (page > 0) showPage(form, page);
}
} catch {
// Invalid saved state — ignore
}
}
function clearSavedState(form: HTMLFormElement) {
const formId = form.dataset.formId;
if (formId) {
try {
sessionStorage.removeItem(STORAGE_PREFIX + formId);
} catch {
// Ignore
}
}
}
function debouncedSave(form: HTMLFormElement) {
const formId = form.dataset.formId;
if (!formId) return;
const existing = saveTimers.get(formId);
if (existing) clearTimeout(existing);
saveTimers.set(
formId,
setTimeout(() => {
saveState(form);
saveTimers.delete(formId);
}, DEBOUNCE_MS),
);
}
// ─── Turnstile ───────────────────────────────────────────────────
function initTurnstile(form: HTMLFormElement) {
const container = form.querySelector<HTMLElement>("[data-ec-turnstile]");
if (!container) return;
const siteKey = container.dataset.sitekey;
if (!siteKey) return;
// Load Turnstile script if not already loaded
if (!document.querySelector('script[src*="turnstile"]')) {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.onload = () => renderTurnstile(container, siteKey);
document.head.appendChild(script);
} else {
renderTurnstile(container, siteKey);
}
}
function renderTurnstile(container: HTMLElement, siteKey: string) {
const w = window as unknown as {
turnstile?: {
render: (el: HTMLElement, opts: Record<string, unknown>) => void;
};
};
if (w.turnstile) {
w.turnstile.render(container, { sitekey: siteKey });
}
}

View File

@@ -0,0 +1,160 @@
/**
* Formatting utilities for email notifications and webhook payloads.
*/
import type { FormDefinition, Submission, SubmissionFile } from "./types.js";
import { getFormFields } from "./types.js";
const CSV_ESCAPE_RE = /[,"\n]/;
const DOUBLE_QUOTE_RE = /"/g;
const CSV_FORMULA_TRIGGERS = new Set(["=", "+", "-", "@", "\t", "\r"]);
/**
* Format a submission as plain text for email notifications.
*/
export function formatSubmissionText(
form: FormDefinition,
data: Record<string, unknown>,
files?: SubmissionFile[],
): string {
const fields = getFormFields(form);
const lines: string[] = [`New submission for "${form.name}"`, ""];
for (const field of fields) {
if (field.type === "hidden") continue;
const value = data[field.name];
if (value === undefined || value === null || value === "") continue;
const display = Array.isArray(value)
? (value as string[]).join(", ")
: String(value as string | number | boolean);
lines.push(`${field.label}: ${display}`);
}
if (files && files.length > 0) {
lines.push("", "Attached files:");
for (const file of files) {
lines.push(` - ${file.filename} (${formatBytes(file.size)})`);
}
}
lines.push("", `Submitted at: ${new Date().toISOString()}`);
return lines.join("\n");
}
/**
* Format a digest email summarizing submissions over a period.
*/
export function formatDigestText(
form: FormDefinition,
formId: string,
submissions: Submission[],
siteUrl: string,
): string {
const lines: string[] = [
`Daily digest for "${form.name}"`,
"",
`${submissions.length} new submission${submissions.length === 1 ? "" : "s"} since last digest.`,
"",
];
for (const sub of submissions.slice(0, 10)) {
const preview = getSubmissionPreview(form, sub);
lines.push(` - ${sub.createdAt}: ${preview}`);
}
if (submissions.length > 10) {
lines.push(` ... and ${submissions.length - 10} more`);
}
lines.push(
"",
`View all submissions: ${siteUrl}/_emdash/admin/plugins/emdash-forms/submissions?formId=${encodeURIComponent(formId)}`,
);
return lines.join("\n");
}
/**
* Format a webhook payload for a new submission.
*/
export function formatWebhookPayload(
form: FormDefinition,
submissionId: string,
data: Record<string, unknown>,
files?: SubmissionFile[],
): Record<string, unknown> {
return {
event: "form.submission",
formId: form.slug,
formName: form.name,
submissionId,
data,
files: files?.map((f) => ({
fieldName: f.fieldName,
filename: f.filename,
contentType: f.contentType,
size: f.size,
mediaId: f.mediaId,
})),
submittedAt: new Date().toISOString(),
};
}
/**
* Format submissions as CSV.
*/
export function formatCsv(
form: FormDefinition,
items: Array<{ id: string; data: Submission }>,
): string {
const fields = getFormFields(form).filter((f) => f.type !== "hidden");
const headers = ["ID", "Submitted At", "Status", ...fields.map((f) => f.label)];
const rows = items.map(({ id, data: sub }) => {
const values = [id, sub.createdAt, sub.status];
for (const field of fields) {
const v = sub.data[field.name];
if (field.type === "file") {
const file = sub.files?.find((f) => f.fieldName === field.name);
values.push(file ? file.filename : "");
} else if (Array.isArray(v)) {
values.push(v.join("; "));
} else {
values.push(v === undefined || v === null ? "" : String(v as string | number | boolean));
}
}
return values;
});
return [headers, ...rows].map((row) => row.map(escapeCsv).join(",")).join("\n");
}
function escapeCsv(value: string): string {
// Neutralize formula triggers to prevent CSV injection in spreadsheet apps
if (value.length > 0 && CSV_FORMULA_TRIGGERS.has(value.charAt(0))) {
value = "'" + value;
}
if (CSV_ESCAPE_RE.test(value)) {
return `"${value.replace(DOUBLE_QUOTE_RE, '""')}"`;
}
return value;
}
function getSubmissionPreview(form: FormDefinition, sub: Submission): string {
const fields = getFormFields(form).filter((f) => f.type !== "hidden" && f.type !== "file");
const previews: string[] = [];
for (const field of fields.slice(0, 3)) {
const v = sub.data[field.name];
if (v !== undefined && v !== null && v !== "") {
const str = String(v as string | number | boolean);
previews.push(str.length > 50 ? `${str.slice(0, 47)}...` : str);
}
}
return previews.join(" | ") || "(empty)";
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,151 @@
/**
* Cron task handlers.
*
* - cleanup: Delete submissions past their retention period
* - digest: Send daily digest emails for forms with digest enabled
*/
import type { PluginContext, StorageCollection } from "emdash";
import { formatDigestText } from "../format.js";
import type { FormDefinition, Submission } from "../types.js";
/** Typed access to plugin storage collections */
function forms(ctx: PluginContext): StorageCollection<FormDefinition> {
return ctx.storage.forms as StorageCollection<FormDefinition>;
}
function submissions(ctx: PluginContext): StorageCollection<Submission> {
return ctx.storage.submissions as StorageCollection<Submission>;
}
/**
* Weekly cleanup: delete submissions past retention period.
*/
export async function handleCleanup(ctx: PluginContext) {
let formsCursor: string | undefined;
do {
const formsBatch = await forms(ctx).query({ limit: 100, cursor: formsCursor });
for (const formItem of formsBatch.items) {
const form = formItem.data;
if (form.settings.retentionDays === 0) continue;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - form.settings.retentionDays);
const cutoffStr = cutoff.toISOString();
let cursor: string | undefined;
let deletedCount = 0;
do {
const batch = await submissions(ctx).query({
where: {
formId: formItem.id,
createdAt: { lt: cutoffStr },
},
limit: 100,
cursor,
});
// Delete media files
if (ctx.media && "delete" in ctx.media) {
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
for (const item of batch.items) {
if (item.data.files) {
for (const file of item.data.files) {
await mediaWithDelete.delete(file.mediaId).catch(() => {});
}
}
}
}
const ids = batch.items.map((item) => item.id);
if (ids.length > 0) {
await submissions(ctx).deleteMany(ids);
deletedCount += ids.length;
}
cursor = batch.cursor;
} while (cursor);
// Update form counter
if (deletedCount > 0) {
const count = await submissions(ctx).count({ formId: formItem.id });
await forms(ctx).put(formItem.id, {
...form,
submissionCount: count,
});
ctx.log.info("Cleaned up expired submissions", {
formId: formItem.id,
formName: form.name,
deleted: deletedCount,
});
}
}
formsCursor = formsBatch.cursor;
} while (formsCursor);
}
/**
* Daily digest: send summary email for a specific form.
*
* The cron task name contains the form ID: "digest:{formId}"
*/
export async function handleDigest(formId: string, ctx: PluginContext) {
const form = await forms(ctx).get(formId);
if (!form) {
ctx.log.warn("Digest: form not found, cancelling", { formId });
if (ctx.cron) {
await ctx.cron.cancel(`digest:${formId}`).catch(() => {});
}
return;
}
if (!form.settings.digestEnabled || form.settings.notifyEmails.length === 0) {
return;
}
if (!ctx.email) {
ctx.log.warn("Digest: email not configured", { formId });
return;
}
// Get submissions since last 24 hours
const since = new Date();
since.setDate(since.getDate() - 1);
const recent = await submissions(ctx).query({
where: {
formId,
createdAt: { gte: since.toISOString() },
},
orderBy: { createdAt: "desc" },
limit: 100,
});
if (recent.items.length === 0) {
return;
}
const subs = recent.items.map((item) => item.data);
const text = formatDigestText(form, formId, subs, ctx.site.url);
for (const email of form.settings.notifyEmails) {
await ctx.email
.send({
to: email,
subject: `Daily digest: ${form.name} (${subs.length} new)`,
text,
})
.catch((err: unknown) => {
ctx.log.error("Failed to send digest email", {
error: String(err),
to: email,
});
});
}
}

View File

@@ -0,0 +1,269 @@
/**
* Form CRUD route handlers.
*
* Admin-only routes for managing form definitions.
*/
import type { RouteContext, StorageCollection } from "emdash";
import { PluginRouteError } from "emdash";
import { ulid } from "ulidx";
import type {
FormCreateInput,
FormDeleteInput,
FormDuplicateInput,
FormUpdateInput,
} from "../schemas.js";
import type { FormDefinition } from "../types.js";
/** Typed access to plugin storage collections */
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
return ctx.storage.forms as StorageCollection<FormDefinition>;
}
function submissions(ctx: RouteContext): StorageCollection {
return ctx.storage.submissions as StorageCollection;
}
// ─── List Forms ──────────────────────────────────────────────────
export async function formsListHandler(ctx: RouteContext) {
const result = await forms(ctx).query({
orderBy: { createdAt: "desc" },
limit: 100,
});
return {
items: result.items.map((item) => ({ id: item.id, ...item.data })),
hasMore: result.hasMore,
cursor: result.cursor,
};
}
// ─── Create Form ─────────────────────────────────────────────────
export async function formsCreateHandler(ctx: RouteContext<FormCreateInput>) {
const input = ctx.input;
// Check slug uniqueness
const existing = await forms(ctx).query({
where: { slug: input.slug },
limit: 1,
});
if (existing.items.length > 0) {
throw PluginRouteError.conflict(`A form with slug "${input.slug}" already exists`);
}
// Validate field names are unique across all pages
validateFieldNames(input.pages);
const now = new Date().toISOString();
const id = ulid();
const form: FormDefinition = {
name: input.name,
slug: input.slug,
pages: input.pages,
settings: {
confirmationMessage: input.settings.confirmationMessage ?? "Thank you for your submission.",
redirectUrl: input.settings.redirectUrl || undefined,
notifyEmails: input.settings.notifyEmails ?? [],
digestEnabled: input.settings.digestEnabled ?? false,
digestHour: input.settings.digestHour ?? 9,
autoresponder: input.settings.autoresponder,
webhookUrl: input.settings.webhookUrl || undefined,
retentionDays: input.settings.retentionDays ?? 0,
spamProtection: input.settings.spamProtection ?? "honeypot",
submitLabel: input.settings.submitLabel ?? "Submit",
nextLabel: input.settings.nextLabel,
prevLabel: input.settings.prevLabel,
},
status: "active",
submissionCount: 0,
lastSubmissionAt: null,
createdAt: now,
updatedAt: now,
};
await forms(ctx).put(id, form);
// Schedule digest cron if enabled
if (form.settings.digestEnabled && ctx.cron) {
await ctx.cron.schedule(`digest:${id}`, {
schedule: `0 ${form.settings.digestHour} * * *`,
});
}
return { id, ...form };
}
// ─── Update Form ─────────────────────────────────────────────────
export async function formsUpdateHandler(ctx: RouteContext<FormUpdateInput>) {
const input = ctx.input;
const existing = await forms(ctx).get(input.id);
if (!existing) {
throw PluginRouteError.notFound("Form not found");
}
// Check slug uniqueness if changing
if (input.slug && input.slug !== existing.slug) {
const slugCheck = await forms(ctx).query({
where: { slug: input.slug },
limit: 1,
});
if (slugCheck.items.length > 0) {
throw PluginRouteError.conflict(`A form with slug "${input.slug}" already exists`);
}
}
if (input.pages) {
validateFieldNames(input.pages);
}
const updated: FormDefinition = {
...existing,
name: input.name ?? existing.name,
slug: input.slug ?? existing.slug,
pages: input.pages ?? existing.pages,
settings: input.settings ? { ...existing.settings, ...input.settings } : existing.settings,
status: input.status ?? existing.status,
updatedAt: new Date().toISOString(),
};
// Clean up empty strings
if (updated.settings.redirectUrl === "") updated.settings.redirectUrl = undefined;
if (updated.settings.webhookUrl === "") updated.settings.webhookUrl = undefined;
await forms(ctx).put(input.id, updated);
// Update digest cron if settings changed
if (ctx.cron) {
if (updated.settings.digestEnabled && !existing.settings.digestEnabled) {
await ctx.cron.schedule(`digest:${input.id}`, {
schedule: `0 ${updated.settings.digestHour} * * *`,
});
} else if (!updated.settings.digestEnabled && existing.settings.digestEnabled) {
await ctx.cron.cancel(`digest:${input.id}`);
} else if (
updated.settings.digestEnabled &&
updated.settings.digestHour !== existing.settings.digestHour
) {
await ctx.cron.schedule(`digest:${input.id}`, {
schedule: `0 ${updated.settings.digestHour} * * *`,
});
}
}
return { id: input.id, ...updated };
}
// ─── Delete Form ─────────────────────────────────────────────────
export async function formsDeleteHandler(ctx: RouteContext<FormDeleteInput>) {
const input = ctx.input;
const existing = await forms(ctx).get(input.id);
if (!existing) {
throw PluginRouteError.notFound("Form not found");
}
// Delete associated submissions if requested
if (input.deleteSubmissions) {
await deleteFormSubmissions(input.id, ctx);
}
// Cancel digest cron
if (ctx.cron) {
await ctx.cron.cancel(`digest:${input.id}`).catch(() => {});
}
await forms(ctx).delete(input.id);
return { deleted: true };
}
// ─── Duplicate Form ──────────────────────────────────────────────
export async function formsDuplicateHandler(ctx: RouteContext<FormDuplicateInput>) {
const input = ctx.input;
const existing = await forms(ctx).get(input.id);
if (!existing) {
throw PluginRouteError.notFound("Form not found");
}
const newSlug = input.slug ?? `${existing.slug}-copy`;
const newName = input.name ?? `${existing.name} (Copy)`;
// Check slug uniqueness
const slugCheck = await forms(ctx).query({
where: { slug: newSlug },
limit: 1,
});
if (slugCheck.items.length > 0) {
throw PluginRouteError.conflict(`A form with slug "${newSlug}" already exists`);
}
const now = new Date().toISOString();
const id = ulid();
const duplicate: FormDefinition = {
...existing,
name: newName,
slug: newSlug,
submissionCount: 0,
lastSubmissionAt: null,
createdAt: now,
updatedAt: now,
};
await forms(ctx).put(id, duplicate);
return { id, ...duplicate };
}
// ─── Helpers ─────────────────────────────────────────────────────
function validateFieldNames(pages: Array<{ fields: Array<{ name: string }> }>) {
const names = new Set<string>();
for (const page of pages) {
for (const field of page.fields) {
if (names.has(field.name)) {
throw PluginRouteError.badRequest(`Duplicate field name "${field.name}" across form pages`);
}
names.add(field.name);
}
}
}
/** Delete all submissions for a form, including media files */
async function deleteFormSubmissions(formId: string, ctx: RouteContext) {
let cursor: string | undefined;
do {
const batch = await submissions(ctx).query({
where: { formId },
limit: 100,
cursor,
});
// Delete associated media files
if (ctx.media && "delete" in ctx.media) {
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
for (const item of batch.items) {
const sub = item.data as { files?: Array<{ mediaId: string }> };
if (sub.files) {
for (const file of sub.files) {
await mediaWithDelete.delete(file.mediaId).catch(() => {});
}
}
}
}
const ids = batch.items.map((item) => item.id);
if (ids.length > 0) {
await submissions(ctx).deleteMany(ids);
}
cursor = batch.cursor;
} while (cursor);
}

View File

@@ -0,0 +1,191 @@
/**
* Submission management route handlers.
*
* Admin-only routes for viewing, updating, exporting, and deleting submissions.
*/
import type { RouteContext, StorageCollection } from "emdash";
import { PluginRouteError } from "emdash";
import { formatCsv } from "../format.js";
import type {
ExportInput,
SubmissionDeleteInput,
SubmissionGetInput,
SubmissionsListInput,
SubmissionUpdateInput,
} from "../schemas.js";
import type { FormDefinition, Submission } from "../types.js";
/** Typed access to plugin storage collections */
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
return ctx.storage.forms as StorageCollection<FormDefinition>;
}
function submissions(ctx: RouteContext): StorageCollection<Submission> {
return ctx.storage.submissions as StorageCollection<Submission>;
}
// ─── List Submissions ────────────────────────────────────────────
export async function submissionsListHandler(ctx: RouteContext<SubmissionsListInput>) {
const input = ctx.input;
const result = await submissions(ctx).query({
where: {
formId: input.formId,
...(input.status ? { status: input.status } : {}),
...(input.starred !== undefined ? { starred: input.starred } : {}),
},
orderBy: { createdAt: "desc" },
limit: input.limit,
cursor: input.cursor,
});
return {
items: result.items.map((item) => ({ id: item.id, ...item.data })),
hasMore: result.hasMore,
cursor: result.cursor,
};
}
// ─── Get Single Submission ───────────────────────────────────────
export async function submissionGetHandler(ctx: RouteContext<SubmissionGetInput>) {
const sub = await submissions(ctx).get(ctx.input.id);
if (!sub) {
throw PluginRouteError.notFound("Submission not found");
}
return { id: ctx.input.id, ...sub };
}
// ─── Update Submission ───────────────────────────────────────────
export async function submissionUpdateHandler(ctx: RouteContext<SubmissionUpdateInput>) {
const input = ctx.input;
const existing = await submissions(ctx).get(input.id);
if (!existing) {
throw PluginRouteError.notFound("Submission not found");
}
const updated: Submission = {
...existing,
status: input.status ?? existing.status,
starred: input.starred ?? existing.starred,
notes: input.notes !== undefined ? input.notes : existing.notes,
};
await submissions(ctx).put(input.id, updated);
return { id: input.id, ...updated };
}
// ─── Delete Submission ───────────────────────────────────────────
export async function submissionDeleteHandler(ctx: RouteContext<SubmissionDeleteInput>) {
const input = ctx.input;
const existing = await submissions(ctx).get(input.id);
if (!existing) {
throw PluginRouteError.notFound("Submission not found");
}
// Delete associated media files
if (existing.files && ctx.media && "delete" in ctx.media) {
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
for (const file of existing.files) {
await mediaWithDelete.delete(file.mediaId).catch(() => {});
}
}
await submissions(ctx).delete(input.id);
// Update form counter using count() to avoid race conditions
if (existing.formId) {
const form = await forms(ctx).get(existing.formId);
if (form) {
const count = await submissions(ctx).count({ formId: existing.formId });
await forms(ctx).put(existing.formId, {
...form,
submissionCount: count,
});
}
}
return { deleted: true };
}
// <20><>── Export Submissions ──────────────────────────────────────────
export async function exportHandler(ctx: RouteContext<ExportInput>) {
const input = ctx.input;
// Load form definition
let form: FormDefinition | null = null;
const byId = await forms(ctx).get(input.formId);
if (byId) {
form = byId;
} else {
const bySlug = await forms(ctx).query({
where: { slug: input.formId },
limit: 1,
});
if (bySlug.items.length > 0) {
form = bySlug.items[0]!.data;
}
}
if (!form) {
throw PluginRouteError.notFound("Form not found");
}
// Build where clause
const where: Record<string, string | number | boolean | null | Record<string, string>> = {
formId: input.formId,
};
if (input.status) where.status = input.status;
if (input.from || input.to) {
const range: Record<string, string> = {};
if (input.from) range.gte = input.from;
if (input.to) range.lte = input.to;
where.createdAt = range;
}
// Collect all submissions (paginate through)
const allItems: Array<{ id: string; data: Submission }> = [];
let cursor: string | undefined;
do {
const batch = await submissions(ctx).query({
where: where as Record<string, string | number | boolean | null>,
orderBy: { createdAt: "desc" },
limit: 100,
cursor,
});
for (const item of batch.items) {
allItems.push(item);
}
cursor = batch.cursor;
} while (cursor);
if (input.format === "json") {
return {
data: allItems.map((item) => item.data),
count: allItems.length,
contentType: "application/json",
};
}
// CSV
const csv = formatCsv(form, allItems);
return {
data: csv,
count: allItems.length,
contentType: "text/csv",
filename: `${form.slug}-submissions-${new Date().toISOString().split("T")[0]}.csv`,
};
}

View File

@@ -0,0 +1,297 @@
/**
* Public form submission handler.
*
* This is the main entry point for form submissions from anonymous visitors.
* Handles spam protection, validation, file uploads, notifications, and webhooks.
*/
import type { RouteContext, StorageCollection } from "emdash";
import { PluginRouteError } from "emdash";
import { ulid } from "ulidx";
import { formatSubmissionText, formatWebhookPayload } from "../format.js";
import type { SubmitInput } from "../schemas.js";
import { verifyTurnstile } from "../turnstile.js";
import type { FormDefinition, Submission, SubmissionFile } from "../types.js";
import { getFormFields } from "../types.js";
import { validateSubmission } from "../validation.js";
/** Typed access to plugin storage collections */
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
return ctx.storage.forms as StorageCollection<FormDefinition>;
}
function submissions(ctx: RouteContext): StorageCollection<Submission> {
return ctx.storage.submissions as StorageCollection<Submission>;
}
export async function submitHandler(ctx: RouteContext<SubmitInput>) {
const input = ctx.input;
// 1. Load form definition (by ID first, then by slug)
let formId = input.formId;
let form = await forms(ctx).get(formId);
if (!form) {
const bySlug = await forms(ctx).query({
where: { slug: input.formId },
limit: 1,
});
if (bySlug.items.length > 0) {
formId = bySlug.items[0]!.id;
form = bySlug.items[0]!.data;
}
}
if (!form) {
throw PluginRouteError.notFound("Form not found");
}
if (form.status === "paused") {
throw new PluginRouteError(
"FORM_PAUSED",
"This form is not currently accepting submissions",
410,
);
}
const settings = form.settings;
// 2. Spam protection
if (settings.spamProtection === "turnstile") {
const token = input.data["cf-turnstile-response"];
if (typeof token !== "string" || !token) {
throw PluginRouteError.forbidden("Spam verification required");
}
const secretKey = await ctx.kv.get<string>("settings:turnstileSecretKey");
if (!secretKey || !ctx.http) {
throw PluginRouteError.internal("Turnstile is not configured");
}
const result = await verifyTurnstile(
token,
secretKey,
ctx.http.fetch.bind(ctx.http),
ctx.requestMeta.ip,
);
if (!result.success) {
ctx.log.warn("Turnstile verification failed", {
errorCodes: result.errorCodes,
});
throw PluginRouteError.forbidden("Spam verification failed. Please try again.");
}
}
if (settings.spamProtection === "honeypot") {
if (input.data._hp) {
// Honeypot triggered — return success silently
return {
success: true,
message: settings.confirmationMessage,
};
}
}
// 3. Validate submission data
const allFields = getFormFields(form);
const result = validateSubmission(allFields, input.data);
if (!result.valid) {
throw PluginRouteError.badRequest("Validation failed", { errors: result.errors });
}
// 4. Upload files
const files: SubmissionFile[] = [];
if (input.files && ctx.media && "upload" in ctx.media) {
const mediaWithWrite = ctx.media as {
upload(
filename: string,
contentType: string,
bytes: ArrayBuffer,
): Promise<{ mediaId: string; storageKey: string; url: string }>;
};
for (const field of allFields.filter((f) => f.type === "file")) {
const fileData = input.files[field.name];
if (!fileData) continue;
// Validate file type
if (field.validation?.accept) {
const allowed = field.validation.accept.split(",").map((s) => s.trim().toLowerCase());
const ext = `.${fileData.filename.split(".").pop()?.toLowerCase()}`;
const typeMatch = allowed.some(
(a) =>
a === ext ||
a === fileData.contentType ||
fileData.contentType.startsWith(a.replace("/*", "/")),
);
if (!typeMatch) {
throw PluginRouteError.badRequest(`File type not allowed for ${field.label}`);
}
}
// Validate file size
if (
field.validation?.maxFileSize &&
fileData.bytes.byteLength > field.validation.maxFileSize
) {
throw PluginRouteError.badRequest(
`File too large for ${field.label}. Maximum: ${Math.round(field.validation.maxFileSize / 1024)} KB`,
);
}
const uploaded = await mediaWithWrite.upload(
fileData.filename,
fileData.contentType,
fileData.bytes,
);
files.push({
fieldName: field.name,
filename: fileData.filename,
contentType: fileData.contentType,
size: fileData.bytes.byteLength,
mediaId: uploaded.mediaId,
});
}
}
// 5. Store submission
const submissionId = ulid();
const submission: Submission = {
formId,
data: result.data,
files: files.length > 0 ? files : undefined,
status: "new",
starred: false,
createdAt: new Date().toISOString(),
meta: {
ip: ctx.requestMeta.ip,
userAgent: ctx.requestMeta.userAgent,
referer: ctx.requestMeta.referer,
country: ctx.requestMeta.geo?.country ?? null,
},
};
await submissions(ctx).put(submissionId, submission);
// 6. Update form counters (use count() to avoid race conditions
// from concurrent submissions doing read-modify-write)
const submissionCount = await submissions(ctx).count({ formId });
await forms(ctx).put(formId, {
...form,
submissionCount,
lastSubmissionAt: new Date().toISOString(),
});
// 7. Immediate email notifications (not digest)
if (settings.notifyEmails.length > 0 && !settings.digestEnabled && ctx.email) {
const text = formatSubmissionText(form, result.data, files);
for (const email of settings.notifyEmails) {
await ctx.email
.send({
to: email,
subject: `New submission: ${form.name}`,
text,
})
.catch((err: unknown) => {
ctx.log.error("Failed to send notification email", {
error: String(err),
to: email,
});
});
}
}
// 8. Autoresponder
if (settings.autoresponder && ctx.email) {
const emailField = allFields.find((f) => f.type === "email");
const submitterEmail = emailField ? result.data[emailField.name] : null;
if (typeof submitterEmail === "string" && submitterEmail) {
await ctx.email
.send({
to: submitterEmail,
subject: settings.autoresponder.subject,
text: settings.autoresponder.body,
})
.catch((err: unknown) => {
ctx.log.error("Failed to send autoresponder", { error: String(err) });
});
}
}
// 9. Webhook (fire and forget)
if (settings.webhookUrl && ctx.http) {
const payload = formatWebhookPayload(form, submissionId, result.data, files);
ctx.http
.fetch(settings.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.catch((err: unknown) => {
ctx.log.error("Webhook failed", {
error: String(err),
url: settings.webhookUrl,
});
});
}
// 10. Return success
return {
success: true,
message: settings.confirmationMessage,
redirect: settings.redirectUrl,
};
}
// ─── Public Form Definition Endpoint ─────────────────────────────
export async function definitionHandler(
ctx: RouteContext<import("../schemas.js").DefinitionInput>,
) {
const { id } = ctx.input;
// Look up by ID first, then by slug
let form = await forms(ctx).get(id);
if (!form) {
const bySlug = await forms(ctx).query({
where: { slug: id },
limit: 1,
});
if (bySlug.items.length > 0) {
form = bySlug.items[0]!.data;
}
}
if (!form) {
throw PluginRouteError.notFound("Form not found");
}
if (form.status !== "active") {
throw new PluginRouteError("FORM_PAUSED", "This form is not currently available", 410);
}
// Include Turnstile site key if configured
const turnstileSiteKey =
form.settings.spamProtection === "turnstile"
? await ctx.kv.get<string>("settings:turnstileSiteKey")
: null;
// Return only the settings needed for client rendering — never expose
// admin emails, webhook URLs, or other internal configuration.
return {
name: form.name,
slug: form.slug,
pages: form.pages,
settings: {
spamProtection: form.settings.spamProtection,
submitLabel: form.settings.submitLabel,
nextLabel: form.settings.nextLabel,
prevLabel: form.settings.prevLabel,
},
status: form.status,
_turnstileSiteKey: turnstileSiteKey,
};
}

View File

@@ -0,0 +1,230 @@
/**
* Forms Plugin for EmDash CMS
*
* Build forms in the admin, embed them in content via Portable Text,
* accept submissions from anonymous visitors, send notifications, export data.
*
* This is a trusted plugin shipped as an npm package. It uses the standard
* plugin APIs — nothing privileged.
*
* @example
* ```typescript
* // live.config.ts
* import { formsPlugin } from "@emdashcms/plugin-forms";
*
* export default defineConfig({
* plugins: [formsPlugin()],
* });
* ```
*/
import type { PluginDescriptor, ResolvedPlugin } from "emdash";
import { definePlugin } from "emdash";
import { handleCleanup, handleDigest } from "./handlers/cron.js";
import {
formsCreateHandler,
formsDeleteHandler,
formsDuplicateHandler,
formsListHandler,
formsUpdateHandler,
} from "./handlers/forms.js";
import {
exportHandler,
submissionDeleteHandler,
submissionGetHandler,
submissionsListHandler,
submissionUpdateHandler,
} from "./handlers/submissions.js";
import { definitionHandler, submitHandler } from "./handlers/submit.js";
import {
definitionSchema,
exportSchema,
formCreateSchema,
formDeleteSchema,
formDuplicateSchema,
formUpdateSchema,
submissionDeleteSchema,
submissionGetSchema,
submissionsListSchema,
submitSchema,
submissionUpdateSchema,
} from "./schemas.js";
import { FORMS_STORAGE_CONFIG } from "./storage.js";
// ─── Plugin Options ──────────────────────────────────────────────
export interface FormsPluginOptions {
/** Default spam protection for new forms */
defaultSpamProtection?: "none" | "honeypot" | "turnstile";
}
// ─── Plugin Descriptor (for live.config.ts) ──────────────────────
export function formsPlugin(
options: FormsPluginOptions = {},
): PluginDescriptor<FormsPluginOptions> {
return {
id: "emdash-forms",
version: "0.0.1",
entrypoint: "@emdashcms/plugin-forms",
adminEntry: "@emdashcms/plugin-forms/admin",
componentsEntry: "@emdashcms/plugin-forms/astro",
options,
capabilities: ["email:send", "write:media", "network:fetch"],
allowedHosts: ["*"],
adminPages: [
{ path: "/", label: "Forms", icon: "list" },
{ path: "/submissions", label: "Submissions", icon: "inbox" },
],
adminWidgets: [{ id: "recent-submissions", title: "Recent Submissions", size: "half" }],
// Descriptor uses flat indexes only; composite indexes are in definePlugin
storage: {
forms: { indexes: ["status", "createdAt"], uniqueIndexes: ["slug"] },
submissions: { indexes: ["formId", "status", "starred", "createdAt"] },
},
};
}
// ─── Plugin Implementation ───────────────────────────────────────
export function createPlugin(_options: FormsPluginOptions = {}): ResolvedPlugin {
return definePlugin({
id: "emdash-forms",
version: "0.0.1",
capabilities: ["email:send", "write:media", "network:fetch"],
allowedHosts: ["*"],
storage: FORMS_STORAGE_CONFIG,
hooks: {
"plugin:activate": {
handler: async (_event, ctx) => {
// Schedule weekly cleanup for expired submissions
if (ctx.cron) {
await ctx.cron.schedule("cleanup", { schedule: "@weekly" });
}
},
},
cron: {
handler: async (event, ctx) => {
if (event.name === "cleanup") {
await handleCleanup(ctx);
} else if (event.name.startsWith("digest:")) {
const formId = event.name.slice("digest:".length);
await handleDigest(formId, ctx);
}
},
},
},
// Route handlers are typed with specific input schemas but the route record
// erases the generic to `unknown`. The cast is safe because the input schema
// guarantees the runtime shape matches the handler's expected type.
routes: {
// --- Public routes ---
submit: {
public: true,
input: submitSchema,
handler: submitHandler as never,
},
definition: {
public: true,
input: definitionSchema,
handler: definitionHandler as never,
},
// --- Admin routes (require auth) ---
"forms/list": {
handler: formsListHandler,
},
"forms/create": {
input: formCreateSchema,
handler: formsCreateHandler as never,
},
"forms/update": {
input: formUpdateSchema,
handler: formsUpdateHandler as never,
},
"forms/delete": {
input: formDeleteSchema,
handler: formsDeleteHandler as never,
},
"forms/duplicate": {
input: formDuplicateSchema,
handler: formsDuplicateHandler as never,
},
"submissions/list": {
input: submissionsListSchema,
handler: submissionsListHandler as never,
},
"submissions/get": {
input: submissionGetSchema,
handler: submissionGetHandler as never,
},
"submissions/update": {
input: submissionUpdateSchema,
handler: submissionUpdateHandler as never,
},
"submissions/delete": {
input: submissionDeleteSchema,
handler: submissionDeleteHandler as never,
},
"submissions/export": {
input: exportSchema,
handler: exportHandler as never,
},
"settings/turnstile-status": {
handler: async (ctx) => {
const siteKey = await ctx.kv.get<string>("settings:turnstileSiteKey");
const secretKey = await ctx.kv.get<string>("settings:turnstileSecretKey");
return {
hasSiteKey: !!siteKey,
hasSecretKey: !!secretKey,
};
},
},
},
admin: {
settingsSchema: {
turnstileSiteKey: { type: "string", label: "Turnstile Site Key" },
turnstileSecretKey: { type: "secret", label: "Turnstile Secret Key" },
},
pages: [
{ path: "/", label: "Forms", icon: "list" },
{ path: "/submissions", label: "Submissions", icon: "inbox" },
],
widgets: [{ id: "recent-submissions", title: "Recent Submissions", size: "half" }],
portableTextBlocks: [
{
type: "emdash-form",
label: "Form",
icon: "form",
description: "Embed a form",
fields: [
{
type: "select",
action_id: "formId",
label: "Form",
options: [],
optionsRoute: "forms/list",
},
],
},
],
},
});
}
export default createPlugin;
// Re-export types for consumers
export type * from "./types.js";
export type { FormsStorage } from "./storage.js";

View File

@@ -0,0 +1,215 @@
/**
* Zod schemas for route input validation.
*/
import { z } from "astro/zod";
/** Matches http(s) scheme at start of URL */
const HTTP_SCHEME_RE = /^https?:\/\//i;
/** Validates that a URL string uses http or https scheme. Rejects javascript:/data: URI XSS vectors. */
const httpUrl = z
.string()
.url()
.refine((url) => HTTP_SCHEME_RE.test(url), "URL must use http or https");
// ─── Field Schemas ───────────────────────────────────────────────
const fieldOptionSchema = z.object({
label: z.string().min(1),
value: z.string().min(1),
});
const fieldValidationSchema = z
.object({
minLength: z.number().int().min(0).optional(),
maxLength: z.number().int().min(1).optional(),
min: z.number().optional(),
max: z.number().optional(),
pattern: z.string().optional(),
patternMessage: z.string().optional(),
accept: z.string().optional(),
maxFileSize: z.number().int().min(1).optional(),
})
.optional();
const fieldConditionSchema = z
.object({
field: z.string().min(1),
op: z.enum(["eq", "neq", "filled", "empty"]),
value: z.string().optional(),
})
.optional();
export const fieldTypeSchema = z.enum([
"text",
"email",
"textarea",
"number",
"tel",
"url",
"date",
"select",
"radio",
"checkbox",
"checkbox-group",
"file",
"hidden",
]);
const formFieldSchema = z.object({
id: z.string().min(1),
type: fieldTypeSchema,
label: z.string().min(1),
name: z
.string()
.min(1)
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, "Invalid field name"),
placeholder: z.string().optional(),
helpText: z.string().optional(),
required: z.boolean(),
validation: fieldValidationSchema,
options: z.array(fieldOptionSchema).optional(),
defaultValue: z.string().optional(),
width: z.enum(["full", "half"]).default("full"),
condition: fieldConditionSchema,
});
const formPageSchema = z.object({
title: z.string().optional(),
fields: z.array(formFieldSchema).min(1, "Each page must have at least one field"),
});
// ─── Settings Schema ─────────────────────────────────────────────
const autoresponderSchema = z
.object({
subject: z.string().min(1),
body: z.string().min(1),
})
.optional();
const formSettingsSchema = z.object({
confirmationMessage: z.string().min(1).default("Thank you for your submission."),
redirectUrl: httpUrl.optional().or(z.literal("")),
notifyEmails: z.array(z.string().email()).default([]),
digestEnabled: z.boolean().default(false),
digestHour: z.number().int().min(0).max(23).default(9),
autoresponder: autoresponderSchema,
webhookUrl: httpUrl.optional().or(z.literal("")),
retentionDays: z.number().int().min(0).default(0),
spamProtection: z.enum(["none", "honeypot", "turnstile"]).default("honeypot"),
submitLabel: z.string().min(1).default("Submit"),
nextLabel: z.string().optional(),
prevLabel: z.string().optional(),
});
// ─── Form CRUD Schemas ──────────────────────────────────────────
export const formCreateSchema = z.object({
name: z.string().min(1).max(200),
slug: z
.string()
.min(1)
.max(100)
.regex(/^[a-z][a-z0-9-]*$/, "Slug must be lowercase alphanumeric with hyphens"),
pages: z.array(formPageSchema).min(1),
settings: formSettingsSchema,
});
export const formUpdateSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(200).optional(),
slug: z
.string()
.min(1)
.max(100)
.regex(/^[a-z][a-z0-9-]*$/)
.optional(),
pages: z.array(formPageSchema).min(1).optional(),
settings: formSettingsSchema.partial().optional(),
status: z.enum(["active", "paused"]).optional(),
});
export const formDeleteSchema = z.object({
id: z.string().min(1),
deleteSubmissions: z.boolean().default(true),
});
export const formDuplicateSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(200).optional(),
slug: z
.string()
.min(1)
.max(100)
.regex(/^[a-z][a-z0-9-]*$/)
.optional(),
});
export const definitionSchema = z.object({
id: z.string().min(1),
});
export type DefinitionInput = z.infer<typeof definitionSchema>;
// ─── Submission Schemas ──────────────────────────────────────────
export const submitSchema = z.object({
formId: z.string().min(1),
data: z.record(z.string(), z.unknown()),
files: z
.record(
z.string(),
z.object({
filename: z.string(),
contentType: z.string(),
bytes: z.custom<ArrayBuffer>(),
}),
)
.optional(),
});
export const submissionsListSchema = z.object({
formId: z.string().min(1),
status: z.enum(["new", "read", "archived"]).optional(),
starred: z.boolean().optional(),
cursor: z.string().optional(),
limit: z.number().int().min(1).max(100).default(50),
});
export const submissionGetSchema = z.object({
id: z.string().min(1),
});
export const submissionUpdateSchema = z.object({
id: z.string().min(1),
status: z.enum(["new", "read", "archived"]).optional(),
starred: z.boolean().optional(),
notes: z.string().optional(),
});
export const submissionDeleteSchema = z.object({
id: z.string().min(1),
});
export const exportSchema = z.object({
formId: z.string().min(1),
format: z.enum(["csv", "json"]).default("csv"),
status: z.enum(["new", "read", "archived"]).optional(),
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
});
// ─── Type Exports ────────────────────────────────────────────────
export type FormCreateInput = z.infer<typeof formCreateSchema>;
export type FormUpdateInput = z.infer<typeof formUpdateSchema>;
export type FormDeleteInput = z.infer<typeof formDeleteSchema>;
export type FormDuplicateInput = z.infer<typeof formDuplicateSchema>;
export type SubmitInput = z.infer<typeof submitSchema>;
export type SubmissionsListInput = z.infer<typeof submissionsListSchema>;
export type SubmissionGetInput = z.infer<typeof submissionGetSchema>;
export type SubmissionUpdateInput = z.infer<typeof submissionUpdateSchema>;
export type SubmissionDeleteInput = z.infer<typeof submissionDeleteSchema>;
export type ExportInput = z.infer<typeof exportSchema>;

View File

@@ -0,0 +1,41 @@
/**
* Storage type definition for the forms plugin.
*
* Declares the two storage collections and their indexes.
*/
import type { PluginStorageConfig } from "emdash";
export type FormsStorage = PluginStorageConfig & {
forms: {
indexes: ["status", "createdAt"];
uniqueIndexes: ["slug"];
};
submissions: {
indexes: [
"formId",
"status",
"starred",
"createdAt",
["formId", "createdAt"],
["formId", "status"],
];
};
};
export const FORMS_STORAGE_CONFIG = {
forms: {
indexes: ["status", "createdAt"] as const,
uniqueIndexes: ["slug"] as const,
},
submissions: {
indexes: [
"formId",
"status",
"starred",
"createdAt",
["formId", "createdAt"],
["formId", "status"],
] as const,
},
} satisfies PluginStorageConfig;

View File

@@ -0,0 +1,200 @@
/**
* Optional minimal styles for EmDash forms.
*
* Uses CSS custom properties for theming.
* Import this stylesheet in your site to get basic form styling:
*
* import "@emdashcms/plugin-forms/styles";
*/
.ec-form {
--ec-form-gap: 1rem;
--ec-form-field-border: 1px solid #d1d5db;
--ec-form-field-radius: 6px;
--ec-form-field-padding: 0.5rem 0.75rem;
--ec-form-field-bg: #fff;
--ec-form-error-color: #dc2626;
--ec-form-required-color: #dc2626;
--ec-form-help-color: #6b7280;
--ec-form-submit-bg: #111827;
--ec-form-submit-color: #fff;
--ec-form-submit-radius: 6px;
--ec-form-submit-padding: 0.625rem 1.25rem;
display: flex;
flex-direction: column;
gap: var(--ec-form-gap);
}
.ec-form-page {
display: flex;
flex-wrap: wrap;
gap: var(--ec-form-gap);
border: none;
margin: 0;
padding: 0;
}
.ec-form-page-title {
width: 100%;
font-size: 1.125rem;
font-weight: 600;
padding: 0;
margin-bottom: 0.25rem;
}
.ec-form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.ec-form-field--half {
width: calc(50% - var(--ec-form-gap) / 2);
}
.ec-form-label {
font-size: 0.875rem;
font-weight: 500;
}
.ec-form-required {
color: var(--ec-form-required-color);
margin-left: 0.125rem;
}
.ec-form-input,
.ec-form select,
.ec-form textarea {
border: var(--ec-form-field-border);
border-radius: var(--ec-form-field-radius);
padding: var(--ec-form-field-padding);
background: var(--ec-form-field-bg);
font: inherit;
width: 100%;
box-sizing: border-box;
}
.ec-form-input:focus,
.ec-form select:focus,
.ec-form textarea:focus {
outline: 2px solid #2563eb;
outline-offset: -1px;
}
.ec-form textarea {
min-height: 6rem;
resize: vertical;
}
.ec-form-help {
font-size: 0.75rem;
color: var(--ec-form-help-color);
}
.ec-form-error {
font-size: 0.75rem;
color: var(--ec-form-error-color);
min-height: 1em;
}
.ec-form-error:empty {
display: none;
}
.ec-form-radio-group,
.ec-form-checkbox-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
border: none;
padding: 0;
margin: 0;
}
.ec-form-radio-label,
.ec-form-checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
.ec-form-nav {
display: flex;
gap: 0.75rem;
align-items: center;
}
.ec-form-submit,
.ec-form-next {
background: var(--ec-form-submit-bg);
color: var(--ec-form-submit-color);
border: none;
border-radius: var(--ec-form-submit-radius);
padding: var(--ec-form-submit-padding);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.ec-form-submit:hover,
.ec-form-next:hover {
opacity: 0.9;
}
.ec-form-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ec-form-prev {
background: transparent;
border: var(--ec-form-field-border);
border-radius: var(--ec-form-submit-radius);
padding: var(--ec-form-submit-padding);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.ec-form-progress {
font-size: 0.875rem;
color: var(--ec-form-help-color);
text-align: center;
}
.ec-form-status {
padding: 0.75rem;
border-radius: var(--ec-form-field-radius);
font-size: 0.875rem;
}
.ec-form-status:empty {
display: none;
}
.ec-form-status--success {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.ec-form-status--error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.ec-form-turnstile {
margin-top: 0.5rem;
}
/* Responsive: stack half-width fields on small screens */
@media (max-width: 640px) {
.ec-form-field--half {
width: 100%;
}
}

View File

@@ -0,0 +1,51 @@
/**
* Turnstile verification helper.
*
* Verifies a Turnstile token server-side via the Cloudflare API.
*/
const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export interface TurnstileResult {
success: boolean;
errorCodes: string[];
}
/**
* Verify a Turnstile response token.
*
* @param token - The `cf-turnstile-response` token from the client
* @param secretKey - The Turnstile secret key
* @param httpFetch - The capability-gated fetch function from ctx.http
* @param remoteIp - Optional client IP for additional verification
*/
export async function verifyTurnstile(
token: string,
secretKey: string,
httpFetch: (url: string, init?: RequestInit) => Promise<Response>,
remoteIp?: string | null,
): Promise<TurnstileResult> {
const body: Record<string, string> = {
secret: secretKey,
response: token,
};
if (remoteIp) {
body.remoteip = remoteIp;
}
const res = await httpFetch(VERIFY_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = (await res.json()) as {
success: boolean;
"error-codes"?: string[];
};
return {
success: data.success,
errorCodes: data["error-codes"] ?? [],
};
}

View File

@@ -0,0 +1,164 @@
/**
* Core types for the forms plugin.
*
* These define the data model stored in plugin storage.
*/
// ─── Form Definitions ────────────────────────────────────────────
export interface FormDefinition {
name: string;
slug: string;
pages: FormPage[];
settings: FormSettings;
status: "active" | "paused";
submissionCount: number;
lastSubmissionAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface FormPage {
/** Page title shown in multi-page progress indicator. Optional for single-page forms. */
title?: string;
fields: FormField[];
}
export interface FormSettings {
/** Message shown after successful submission */
confirmationMessage: string;
/** Redirect URL after submission (overrides confirmation message) */
redirectUrl?: string;
/** Email addresses for submission notifications */
notifyEmails: string[];
/** Enable daily digest instead of per-submission notifications */
digestEnabled: boolean;
/** Hour (0-23) to send digest, in site timezone */
digestHour: number;
/** Autoresponder email sent to the submitter */
autoresponder?: {
subject: string;
body: string;
};
/** Webhook URL for submission notifications */
webhookUrl?: string;
/** Days to retain submissions (0 = forever) */
retentionDays: number;
/** Spam protection strategy */
spamProtection: "none" | "honeypot" | "turnstile";
/** Submit button text */
submitLabel: string;
/** Label for Next button on multi-page forms */
nextLabel?: string;
/** Label for Previous button on multi-page forms */
prevLabel?: string;
}
// ─── Form Fields ─────────────────────────────────────────────────
export interface FormField {
id: string;
type: FieldType;
label: string;
/** HTML input name, unique per form */
name: string;
placeholder?: string;
helpText?: string;
required: boolean;
validation?: FieldValidation;
/** For select, radio, checkbox-group */
options?: FieldOption[];
defaultValue?: string;
/** Layout hint */
width: "full" | "half";
/** Conditional visibility */
condition?: FieldCondition;
}
export type FieldType =
| "text"
| "email"
| "textarea"
| "number"
| "tel"
| "url"
| "date"
| "select"
| "radio"
| "checkbox"
| "checkbox-group"
| "file"
| "hidden";
export interface FieldValidation {
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
/** Regex pattern */
pattern?: string;
/** Error message for pattern mismatch */
patternMessage?: string;
/** File types, e.g. ".pdf,.doc" */
accept?: string;
/** Max file size in bytes */
maxFileSize?: number;
}
export interface FieldOption {
label: string;
value: string;
}
export interface FieldCondition {
/** Name of the controlling field */
field: string;
op: "eq" | "neq" | "filled" | "empty";
value?: string;
}
// ─── Submissions ─────────────────────────────────────────────────
export interface Submission {
formId: string;
data: Record<string, unknown>;
files?: SubmissionFile[];
status: "new" | "read" | "archived";
starred: boolean;
notes?: string;
createdAt: string;
meta: SubmissionMeta;
}
export interface SubmissionFile {
fieldName: string;
filename: string;
contentType: string;
size: number;
/** Reference to media library item */
mediaId: string;
}
export interface SubmissionMeta {
ip: string | null;
userAgent: string | null;
referer: string | null;
country: string | null;
}
// ─── Helpers ─────────────────────────────────────────────────────
/** Get all fields across all pages */
export function getFormFields(form: FormDefinition): FormField[] {
return form.pages.flatMap((p) => p.fields);
}
/** Check if a form has multiple pages */
export function isMultiPage(form: FormDefinition): boolean {
return form.pages.length > 1;
}
/** Check if a form has any file fields */
export function hasFileFields(form: FormDefinition): boolean {
return getFormFields(form).some((f) => f.type === "file");
}

View File

@@ -0,0 +1,205 @@
/**
* Server-side submission validation.
*
* Validates submitted data against the form's field definitions.
* These rules mirror what the client-side script checks, but server
* validation is authoritative — never trust the client.
*/
import type { FieldType, FormField } from "./types.js";
export interface ValidationError {
field: string;
message: string;
}
export interface ValidationResult {
valid: boolean;
errors: ValidationError[];
/** Sanitized/coerced values */
data: Record<string, unknown>;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_RE = /^https?:\/\/.+/;
const TEL_RE = /^[+\d][\d\s()-]*$/;
/**
* Validate submission data against form field definitions.
*
* Returns sanitized data with proper type coercion and all validation
* errors. Conditionally hidden fields are excluded from validation
* if their condition is not met.
*/
export function validateSubmission(
fields: FormField[],
data: Record<string, unknown>,
): ValidationResult {
const errors: ValidationError[] = [];
const validated: Record<string, unknown> = {};
for (const field of fields) {
// Skip conditionally hidden fields
if (field.condition && !evaluateCondition(field.condition, data)) {
continue;
}
const raw = data[field.name];
const value = typeof raw === "string" ? raw.trim() : raw;
const isEmpty = value === undefined || value === null || value === "";
// Required check
if (field.required && isEmpty) {
errors.push({ field: field.name, message: `${field.label} is required` });
continue;
}
// Skip further validation if empty and not required
if (isEmpty) {
continue;
}
// Type-specific validation
const typeError = validateFieldType(field, value);
if (typeError) {
errors.push({ field: field.name, message: typeError });
continue;
}
// Validation rules
const ruleErrors = validateFieldRules(field, value);
for (const msg of ruleErrors) {
errors.push({ field: field.name, message: msg });
}
if (ruleErrors.length === 0) {
validated[field.name] = coerceValue(field.type, value);
}
}
return { valid: errors.length === 0, errors, data: validated };
}
function validateFieldType(field: FormField, value: unknown): string | null {
if (typeof value !== "string" && field.type !== "checkbox" && field.type !== "number") {
return `${field.label} has an invalid value`;
}
const strValue = String(value);
switch (field.type) {
case "email":
if (!EMAIL_RE.test(strValue)) return `${field.label} must be a valid email address`;
break;
case "url":
if (!URL_RE.test(strValue)) return `${field.label} must be a valid URL`;
break;
case "tel":
if (!TEL_RE.test(strValue)) return `${field.label} must be a valid phone number`;
break;
case "number": {
const num = Number(value);
if (Number.isNaN(num)) return `${field.label} must be a number`;
break;
}
case "date":
if (Number.isNaN(Date.parse(strValue))) return `${field.label} must be a valid date`;
break;
case "select":
case "radio":
if (field.options && !field.options.some((o) => o.value === strValue)) {
return `${field.label} has an invalid selection`;
}
break;
case "checkbox-group": {
const values = Array.isArray(value) ? value : [value];
if (field.options) {
const validValues = new Set(field.options.map((o) => o.value));
for (const v of values) {
if (!validValues.has(String(v))) {
return `${field.label} contains an invalid selection`;
}
}
}
break;
}
}
return null;
}
function validateFieldRules(field: FormField, value: unknown): string[] {
const errors: string[] = [];
const v = field.validation;
if (!v) return errors;
const strValue = String(value);
if (v.minLength !== undefined && strValue.length < v.minLength) {
errors.push(`${field.label} must be at least ${v.minLength} characters`);
}
if (v.maxLength !== undefined && strValue.length > v.maxLength) {
errors.push(`${field.label} must be at most ${v.maxLength} characters`);
}
if (field.type === "number") {
const num = Number(value);
if (v.min !== undefined && num < v.min) {
errors.push(`${field.label} must be at least ${v.min}`);
}
if (v.max !== undefined && num > v.max) {
errors.push(`${field.label} must be at most ${v.max}`);
}
}
if (v.pattern) {
try {
const re = new RegExp(v.pattern);
if (!re.test(strValue)) {
errors.push(v.patternMessage || `${field.label} has an invalid format`);
}
} catch {
// Invalid regex in config — skip pattern check
}
}
return errors;
}
function coerceValue(type: FieldType, value: unknown): unknown {
switch (type) {
case "number":
return Number(value);
case "checkbox":
return value === "on" || value === "true" || value === true;
case "checkbox-group":
return Array.isArray(value) ? value : [value];
default:
return typeof value === "string" ? value.trim() : value;
}
}
function evaluateCondition(
condition: { field: string; op: string; value?: string },
data: Record<string, unknown>,
): boolean {
const fieldValue = data[condition.field];
const strValue =
fieldValue === undefined || fieldValue === null
? ""
: String(fieldValue as string | number | boolean);
const isFilled = strValue !== "";
switch (condition.op) {
case "eq":
return strValue === (condition.value ?? "");
case "neq":
return strValue !== (condition.value ?? "");
case "filled":
return isFilled;
case "empty":
return !isFilled;
default:
return true;
}
}