/** * 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 "@emdash-cms/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 { return { id: "emdash-forms", version: "0.0.1", entrypoint: "@emdash-cms/plugin-forms", adminEntry: "@emdash-cms/plugin-forms/admin", componentsEntry: "@emdash-cms/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("settings:turnstileSiteKey"); const secretKey = await ctx.kv.get("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";