@emdash-cms/plugin-forms 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Client-side form enhancement.
3
+ *
4
+ * Following the same progressive enhancement pattern as Astro's <ClientRouter />,
5
+ * this uses event delegation on `document` — a single set of listeners handles
6
+ * all forms on the page, including forms added after initial load.
7
+ *
8
+ * Features:
9
+ * - AJAX submission (no page reload)
10
+ * - Client-side validation with inline errors
11
+ * - Multi-page navigation with history integration
12
+ * - Conditional field visibility
13
+ * - Session persistence (survives page refreshes)
14
+ * - Turnstile widget injection
15
+ * - File upload with FormData
16
+ */
17
+
18
+ const STORAGE_PREFIX = "ec-form:";
19
+ const DEBOUNCE_MS = 500;
20
+
21
+ let saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
22
+ let listenersRegistered = false;
23
+
24
+ // ─── Initialization ──────────────────────────────────────────────
25
+
26
+ export function initForms() {
27
+ const init = () => {
28
+ document.querySelectorAll<HTMLFormElement>("[data-ec-form]").forEach((form) => {
29
+ if (form.dataset.ecInitialized) return;
30
+ form.dataset.ecInitialized = "1";
31
+ restoreState(form);
32
+ initMultiPage(form);
33
+ initConditions(form);
34
+ initTurnstile(form);
35
+ });
36
+ };
37
+
38
+ // Guard against duplicate listener registration
39
+ if (!listenersRegistered) {
40
+ listenersRegistered = true;
41
+
42
+ // Event delegation — handles all forms, current and future
43
+ document.addEventListener("submit", handleSubmit);
44
+ document.addEventListener("click", handleClick);
45
+ document.addEventListener("input", handleInput);
46
+ document.addEventListener("change", handleChange);
47
+ window.addEventListener("popstate", handlePopState);
48
+
49
+ // Astro ClientRouter fires astro:page-load on every navigation
50
+ document.addEventListener("astro:page-load", init);
51
+
52
+ // Clean up pending save timers before view transitions swap the DOM
53
+ document.addEventListener("astro:before-swap", () => {
54
+ for (const timer of saveTimers.values()) {
55
+ clearTimeout(timer);
56
+ }
57
+ saveTimers.clear();
58
+ });
59
+ }
60
+
61
+ // Fallback for sites without ClientRouter
62
+ if (document.readyState === "loading") {
63
+ document.addEventListener("DOMContentLoaded", init);
64
+ } else {
65
+ init();
66
+ }
67
+ }
68
+
69
+ // ─── Submit Handler ──────────────────────────────────────────────
70
+
71
+ async function handleSubmit(e: Event) {
72
+ const form = (e.target as HTMLElement).closest<HTMLFormElement>("[data-ec-form]");
73
+ if (!form) return;
74
+ e.preventDefault();
75
+
76
+ // Validate current (or last) page
77
+ if (!validateVisibleFields(form)) return;
78
+
79
+ const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
80
+ if (submitBtn) {
81
+ submitBtn.disabled = true;
82
+ submitBtn.textContent = "Submitting...";
83
+ }
84
+
85
+ clearStatus(form);
86
+
87
+ try {
88
+ const hasFiles = form.querySelector<HTMLInputElement>('input[type="file"]');
89
+ let body: BodyInit;
90
+ const headers: Record<string, string> = {};
91
+
92
+ if (hasFiles) {
93
+ body = new FormData(form);
94
+ } else {
95
+ headers["Content-Type"] = "application/json";
96
+ const formData = new FormData(form);
97
+ let formId = "";
98
+ const data: Record<string, unknown> = {};
99
+ // Track keys we've seen to detect multi-value fields (checkbox-group)
100
+ const seen = new Set<string>();
101
+ for (const [key, val] of formData) {
102
+ if (typeof val !== "string") continue;
103
+ if (key === "formId") {
104
+ formId = val;
105
+ } else if (key === "_hp" || key === "cf-turnstile-response") {
106
+ // Include spam fields at top level for server-side checks
107
+ data[key] = val;
108
+ } else if (seen.has(key)) {
109
+ // Multi-value field (checkbox-group) — collect into array
110
+ const existing = data[key];
111
+ if (Array.isArray(existing)) {
112
+ existing.push(val);
113
+ } else {
114
+ data[key] = [existing, val];
115
+ }
116
+ } else {
117
+ seen.add(key);
118
+ data[key] = val;
119
+ }
120
+ }
121
+ body = JSON.stringify({ formId, data });
122
+ }
123
+
124
+ const res = await fetch(form.action, {
125
+ method: "POST",
126
+ headers,
127
+ body,
128
+ });
129
+
130
+ const result = (await res.json()) as {
131
+ success?: boolean;
132
+ message?: string;
133
+ redirect?: string;
134
+ errors?: Array<{ field: string; message: string }>;
135
+ };
136
+
137
+ if (result.success) {
138
+ clearSavedState(form);
139
+ if (result.redirect) {
140
+ window.location.href = result.redirect;
141
+ } else {
142
+ showStatus(form, result.message || "Submitted successfully.", "success");
143
+ form.reset();
144
+ }
145
+ } else if (result.errors) {
146
+ showErrors(form, result.errors);
147
+ } else {
148
+ showStatus(form, "Something went wrong. Please try again.", "error");
149
+ }
150
+ } catch {
151
+ showStatus(form, "Network error. Please try again.", "error");
152
+ } finally {
153
+ if (submitBtn) {
154
+ submitBtn.disabled = false;
155
+ submitBtn.textContent = form.dataset.submitLabel || "Submit";
156
+ }
157
+ }
158
+ }
159
+
160
+ // ─── Click Handler (Prev/Next) ───────────────────────────────────
161
+
162
+ function handleClick(e: Event) {
163
+ const target = e.target as HTMLElement;
164
+
165
+ const nextBtn = target.closest("[data-ec-next]");
166
+ if (nextBtn) {
167
+ const form = nextBtn.closest<HTMLFormElement>("[data-ec-form]");
168
+ if (form) {
169
+ const current = getCurrentPage(form);
170
+ if (validatePage(form, current)) {
171
+ showPage(form, current + 1);
172
+ saveState(form);
173
+ history.pushState({ ecFormPage: current + 1, ecFormId: form.dataset.formId }, "");
174
+ }
175
+ }
176
+ return;
177
+ }
178
+
179
+ const prevBtn = target.closest("[data-ec-prev]");
180
+ if (prevBtn) {
181
+ const form = prevBtn.closest<HTMLFormElement>("[data-ec-form]");
182
+ if (form) {
183
+ const current = getCurrentPage(form);
184
+ if (current > 0) {
185
+ showPage(form, current - 1);
186
+ saveState(form);
187
+ history.pushState({ ecFormPage: current - 1, ecFormId: form.dataset.formId }, "");
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ // ─── Input/Change Handlers ───────────────────────────────────────
194
+
195
+ function handleInput(e: Event) {
196
+ const target = e.target as HTMLElement;
197
+ const form = target.closest<HTMLFormElement>("[data-ec-form]");
198
+ if (!form) return;
199
+
200
+ // Clear field error on input
201
+ const name = (target as HTMLInputElement).name;
202
+ if (name) {
203
+ const errorEl = form.querySelector(`[data-error-for="${name}"]`);
204
+ if (errorEl) errorEl.textContent = "";
205
+ }
206
+
207
+ // Debounced save
208
+ debouncedSave(form);
209
+ }
210
+
211
+ function handleChange(e: Event) {
212
+ const target = e.target as HTMLElement;
213
+ const form = target.closest<HTMLFormElement>("[data-ec-form]");
214
+ if (!form) return;
215
+
216
+ // Evaluate conditions
217
+ evaluateConditions(form);
218
+ }
219
+
220
+ // ─── Popstate Handler ────────────────────────────────────────────
221
+
222
+ function handlePopState(e: PopStateEvent) {
223
+ if (e.state && typeof e.state.ecFormPage === "number" && typeof e.state.ecFormId === "string") {
224
+ const form = document.querySelector<HTMLFormElement>(
225
+ `[data-ec-form][data-form-id="${CSS.escape(e.state.ecFormId)}"]`,
226
+ );
227
+ if (form) {
228
+ const pages = form.querySelectorAll("[data-page]");
229
+ const page = Math.min(e.state.ecFormPage, pages.length - 1);
230
+ showPage(form, Math.max(0, page));
231
+ }
232
+ }
233
+ }
234
+
235
+ // ─── Multi-Page ──────────────────────────────────────────────────
236
+
237
+ function initMultiPage(form: HTMLFormElement) {
238
+ const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
239
+ if (pages.length <= 1) return;
240
+
241
+ // Hide all pages except first
242
+ pages.forEach((page, i) => {
243
+ if (i > 0) {
244
+ page.hidden = true;
245
+ // Remove required from hidden pages to prevent native validation
246
+ page.querySelectorAll<HTMLElement>("[required]").forEach((el) => {
247
+ el.removeAttribute("required");
248
+ el.dataset.wasRequired = "1";
249
+ });
250
+ }
251
+ });
252
+
253
+ // Show next button, hide submit (unless single page)
254
+ const nextBtn = form.querySelector<HTMLButtonElement>("[data-ec-next]");
255
+ const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
256
+ if (nextBtn) nextBtn.hidden = false;
257
+ if (submitBtn) submitBtn.hidden = true;
258
+
259
+ updateProgress(form, 0, pages.length);
260
+ }
261
+
262
+ function showPage(form: HTMLFormElement, pageIndex: number) {
263
+ const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
264
+ const totalPages = pages.length;
265
+
266
+ pages.forEach((page, i) => {
267
+ if (i === pageIndex) {
268
+ page.hidden = false;
269
+ // Restore required attributes
270
+ page.querySelectorAll<HTMLElement>("[data-was-required]").forEach((el) => {
271
+ el.setAttribute("required", "");
272
+ delete el.dataset.wasRequired;
273
+ });
274
+ } else {
275
+ page.hidden = true;
276
+ // Strip required from hidden
277
+ page.querySelectorAll<HTMLElement>("[required]").forEach((el) => {
278
+ el.removeAttribute("required");
279
+ el.dataset.wasRequired = "1";
280
+ });
281
+ }
282
+ });
283
+
284
+ // Update button visibility
285
+ const prevBtn = form.querySelector<HTMLButtonElement>("[data-ec-prev]");
286
+ const nextBtn = form.querySelector<HTMLButtonElement>("[data-ec-next]");
287
+ const submitBtn = form.querySelector<HTMLButtonElement>(".ec-form-submit");
288
+
289
+ if (prevBtn) prevBtn.hidden = pageIndex === 0;
290
+ if (nextBtn) nextBtn.hidden = pageIndex === totalPages - 1;
291
+ if (submitBtn) submitBtn.hidden = pageIndex < totalPages - 1;
292
+
293
+ updateProgress(form, pageIndex, totalPages);
294
+ }
295
+
296
+ function getCurrentPage(form: HTMLFormElement): number {
297
+ const pages = form.querySelectorAll<HTMLFieldSetElement>("[data-page]");
298
+ for (let i = 0; i < pages.length; i++) {
299
+ if (!pages[i]!.hidden) return i;
300
+ }
301
+ return 0;
302
+ }
303
+
304
+ function updateProgress(form: HTMLFormElement, current: number, total: number) {
305
+ const progress = form.querySelector("[data-ec-progress]");
306
+ if (progress) {
307
+ progress.textContent = `Step ${current + 1} of ${total}`;
308
+ }
309
+ }
310
+
311
+ // ─── Validation ──────────────────────────────────────────────────
312
+
313
+ function validatePage(form: HTMLFormElement, pageIndex: number): boolean {
314
+ const page = form.querySelector<HTMLFieldSetElement>(`[data-page="${pageIndex}"]`);
315
+ if (!page) return true;
316
+
317
+ let valid = true;
318
+ page
319
+ .querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
320
+ "input, select, textarea",
321
+ )
322
+ .forEach((input) => {
323
+ if (!input.checkValidity()) {
324
+ valid = false;
325
+ showFieldError(form, input.name, input.validationMessage);
326
+ }
327
+ });
328
+
329
+ return valid;
330
+ }
331
+
332
+ function validateVisibleFields(form: HTMLFormElement): boolean {
333
+ let valid = true;
334
+ form.querySelectorAll<HTMLFieldSetElement>("[data-page]:not([hidden])").forEach((page) => {
335
+ page
336
+ .querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
337
+ "input, select, textarea",
338
+ )
339
+ .forEach((input) => {
340
+ if (!input.checkValidity()) {
341
+ valid = false;
342
+ showFieldError(form, input.name, input.validationMessage);
343
+ }
344
+ });
345
+ });
346
+
347
+ return valid;
348
+ }
349
+
350
+ function showFieldError(form: HTMLFormElement, fieldName: string, message: string) {
351
+ const errorEl = form.querySelector(`[data-error-for="${fieldName}"]`);
352
+ if (errorEl) errorEl.textContent = message;
353
+ }
354
+
355
+ function showErrors(form: HTMLFormElement, errors: Array<{ field: string; message: string }>) {
356
+ for (const err of errors) {
357
+ showFieldError(form, err.field, err.message);
358
+ }
359
+ }
360
+
361
+ // ─── Status Messages ─────────────────────────────────────────────
362
+
363
+ function showStatus(form: HTMLFormElement, message: string, type: "success" | "error") {
364
+ const status = form.querySelector("[data-form-status]");
365
+ if (status) {
366
+ status.textContent = message;
367
+ status.className = `ec-form-status ec-form-status--${type}`;
368
+ }
369
+ }
370
+
371
+ function clearStatus(form: HTMLFormElement) {
372
+ const status = form.querySelector("[data-form-status]");
373
+ if (status) {
374
+ status.textContent = "";
375
+ status.className = "ec-form-status";
376
+ }
377
+ }
378
+
379
+ // ─── Conditional Fields ──────────────────────────────────────────
380
+
381
+ function initConditions(form: HTMLFormElement) {
382
+ evaluateConditions(form);
383
+ }
384
+
385
+ function evaluateConditions(form: HTMLFormElement) {
386
+ form.querySelectorAll<HTMLElement>("[data-condition]").forEach((wrapper) => {
387
+ try {
388
+ const condition = JSON.parse(wrapper.dataset.condition || "{}") as {
389
+ field: string;
390
+ op: string;
391
+ value?: string;
392
+ };
393
+ const input = form.elements.namedItem(condition.field) as HTMLInputElement | null;
394
+ if (!input) return;
395
+
396
+ const value = input.value;
397
+ let visible = true;
398
+
399
+ switch (condition.op) {
400
+ case "eq":
401
+ visible = value === (condition.value ?? "");
402
+ break;
403
+ case "neq":
404
+ visible = value !== (condition.value ?? "");
405
+ break;
406
+ case "filled":
407
+ visible = value !== "";
408
+ break;
409
+ case "empty":
410
+ visible = value === "";
411
+ break;
412
+ }
413
+
414
+ wrapper.hidden = !visible;
415
+ // Disable inputs in hidden fields so they're excluded from FormData
416
+ wrapper.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((el) => {
417
+ el.disabled = !visible;
418
+ });
419
+ } catch {
420
+ // Invalid condition JSON — show field
421
+ }
422
+ });
423
+ }
424
+
425
+ // ─── Session Persistence ─────────────────────────────────────────
426
+
427
+ function saveState(form: HTMLFormElement) {
428
+ const formId = form.dataset.formId;
429
+ if (!formId) return;
430
+
431
+ const page = getCurrentPage(form);
432
+ const values: Record<string, string> = {};
433
+ for (const [key, val] of new FormData(form)) {
434
+ if (typeof val === "string") values[key] = val;
435
+ }
436
+
437
+ try {
438
+ sessionStorage.setItem(
439
+ STORAGE_PREFIX + formId,
440
+ JSON.stringify({ page, values, savedAt: Date.now() }),
441
+ );
442
+ } catch {
443
+ // sessionStorage full or unavailable — ignore
444
+ }
445
+ }
446
+
447
+ function restoreState(form: HTMLFormElement) {
448
+ const formId = form.dataset.formId;
449
+ if (!formId) return;
450
+
451
+ try {
452
+ const raw = sessionStorage.getItem(STORAGE_PREFIX + formId);
453
+ if (!raw) return;
454
+
455
+ const state = JSON.parse(raw) as {
456
+ page: number;
457
+ values: Record<string, string>;
458
+ };
459
+
460
+ // Restore field values
461
+ for (const [name, value] of Object.entries(state.values)) {
462
+ const input = form.elements.namedItem(name);
463
+ if (input && "value" in input) {
464
+ (input as unknown as HTMLInputElement).value = value;
465
+ }
466
+ }
467
+
468
+ // Navigate to saved page (clamped to valid range)
469
+ if (state.page > 0) {
470
+ const pages = form.querySelectorAll("[data-page]");
471
+ const page = Math.min(state.page, pages.length - 1);
472
+ if (page > 0) showPage(form, page);
473
+ }
474
+ } catch {
475
+ // Invalid saved state — ignore
476
+ }
477
+ }
478
+
479
+ function clearSavedState(form: HTMLFormElement) {
480
+ const formId = form.dataset.formId;
481
+ if (formId) {
482
+ try {
483
+ sessionStorage.removeItem(STORAGE_PREFIX + formId);
484
+ } catch {
485
+ // Ignore
486
+ }
487
+ }
488
+ }
489
+
490
+ function debouncedSave(form: HTMLFormElement) {
491
+ const formId = form.dataset.formId;
492
+ if (!formId) return;
493
+
494
+ const existing = saveTimers.get(formId);
495
+ if (existing) clearTimeout(existing);
496
+
497
+ saveTimers.set(
498
+ formId,
499
+ setTimeout(() => {
500
+ saveState(form);
501
+ saveTimers.delete(formId);
502
+ }, DEBOUNCE_MS),
503
+ );
504
+ }
505
+
506
+ // ─── Turnstile ───────────────────────────────────────────────────
507
+
508
+ function initTurnstile(form: HTMLFormElement) {
509
+ const container = form.querySelector<HTMLElement>("[data-ec-turnstile]");
510
+ if (!container) return;
511
+
512
+ const siteKey = container.dataset.sitekey;
513
+ if (!siteKey) return;
514
+
515
+ // Load Turnstile script if not already loaded
516
+ if (!document.querySelector('script[src*="turnstile"]')) {
517
+ const script = document.createElement("script");
518
+ script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
519
+ script.async = true;
520
+ script.onload = () => renderTurnstile(container, siteKey);
521
+ document.head.appendChild(script);
522
+ } else {
523
+ renderTurnstile(container, siteKey);
524
+ }
525
+ }
526
+
527
+ function renderTurnstile(container: HTMLElement, siteKey: string) {
528
+ const w = window as unknown as {
529
+ turnstile?: {
530
+ render: (el: HTMLElement, opts: Record<string, unknown>) => void;
531
+ };
532
+ };
533
+ if (w.turnstile) {
534
+ w.turnstile.render(container, { sitekey: siteKey });
535
+ }
536
+ }
package/src/format.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Formatting utilities for email notifications and webhook payloads.
3
+ */
4
+
5
+ import type { FormDefinition, Submission, SubmissionFile } from "./types.js";
6
+ import { getFormFields } from "./types.js";
7
+
8
+ const CSV_ESCAPE_RE = /[,"\n]/;
9
+ const DOUBLE_QUOTE_RE = /"/g;
10
+ const CSV_FORMULA_TRIGGERS = new Set(["=", "+", "-", "@", "\t", "\r"]);
11
+
12
+ /**
13
+ * Format a submission as plain text for email notifications.
14
+ */
15
+ export function formatSubmissionText(
16
+ form: FormDefinition,
17
+ data: Record<string, unknown>,
18
+ files?: SubmissionFile[],
19
+ ): string {
20
+ const fields = getFormFields(form);
21
+ const lines: string[] = [`New submission for "${form.name}"`, ""];
22
+
23
+ for (const field of fields) {
24
+ if (field.type === "hidden") continue;
25
+ const value = data[field.name];
26
+ if (value === undefined || value === null || value === "") continue;
27
+
28
+ const display = Array.isArray(value)
29
+ ? (value as string[]).join(", ")
30
+ : String(value as string | number | boolean);
31
+ lines.push(`${field.label}: ${display}`);
32
+ }
33
+
34
+ if (files && files.length > 0) {
35
+ lines.push("", "Attached files:");
36
+ for (const file of files) {
37
+ lines.push(` - ${file.filename} (${formatBytes(file.size)})`);
38
+ }
39
+ }
40
+
41
+ lines.push("", `Submitted at: ${new Date().toISOString()}`);
42
+ return lines.join("\n");
43
+ }
44
+
45
+ /**
46
+ * Format a digest email summarizing submissions over a period.
47
+ */
48
+ export function formatDigestText(
49
+ form: FormDefinition,
50
+ formId: string,
51
+ submissions: Submission[],
52
+ siteUrl: string,
53
+ ): string {
54
+ const lines: string[] = [
55
+ `Daily digest for "${form.name}"`,
56
+ "",
57
+ `${submissions.length} new submission${submissions.length === 1 ? "" : "s"} since last digest.`,
58
+ "",
59
+ ];
60
+
61
+ for (const sub of submissions.slice(0, 10)) {
62
+ const preview = getSubmissionPreview(form, sub);
63
+ lines.push(` - ${sub.createdAt}: ${preview}`);
64
+ }
65
+
66
+ if (submissions.length > 10) {
67
+ lines.push(` ... and ${submissions.length - 10} more`);
68
+ }
69
+
70
+ lines.push(
71
+ "",
72
+ `View all submissions: ${siteUrl}/_emdash/admin/plugins/emdash-forms/submissions?formId=${encodeURIComponent(formId)}`,
73
+ );
74
+ return lines.join("\n");
75
+ }
76
+
77
+ /**
78
+ * Format a webhook payload for a new submission.
79
+ */
80
+ export function formatWebhookPayload(
81
+ form: FormDefinition,
82
+ submissionId: string,
83
+ data: Record<string, unknown>,
84
+ files?: SubmissionFile[],
85
+ ): Record<string, unknown> {
86
+ return {
87
+ event: "form.submission",
88
+ formId: form.slug,
89
+ formName: form.name,
90
+ submissionId,
91
+ data,
92
+ files: files?.map((f) => ({
93
+ fieldName: f.fieldName,
94
+ filename: f.filename,
95
+ contentType: f.contentType,
96
+ size: f.size,
97
+ mediaId: f.mediaId,
98
+ })),
99
+ submittedAt: new Date().toISOString(),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Format submissions as CSV.
105
+ */
106
+ export function formatCsv(
107
+ form: FormDefinition,
108
+ items: Array<{ id: string; data: Submission }>,
109
+ ): string {
110
+ const fields = getFormFields(form).filter((f) => f.type !== "hidden");
111
+ const headers = ["ID", "Submitted At", "Status", ...fields.map((f) => f.label)];
112
+
113
+ const rows = items.map(({ id, data: sub }) => {
114
+ const values = [id, sub.createdAt, sub.status];
115
+ for (const field of fields) {
116
+ const v = sub.data[field.name];
117
+ if (field.type === "file") {
118
+ const file = sub.files?.find((f) => f.fieldName === field.name);
119
+ values.push(file ? file.filename : "");
120
+ } else if (Array.isArray(v)) {
121
+ values.push(v.join("; "));
122
+ } else {
123
+ values.push(v === undefined || v === null ? "" : String(v as string | number | boolean));
124
+ }
125
+ }
126
+ return values;
127
+ });
128
+
129
+ return [headers, ...rows].map((row) => row.map(escapeCsv).join(",")).join("\n");
130
+ }
131
+
132
+ function escapeCsv(value: string): string {
133
+ // Neutralize formula triggers to prevent CSV injection in spreadsheet apps
134
+ if (value.length > 0 && CSV_FORMULA_TRIGGERS.has(value.charAt(0))) {
135
+ value = "'" + value;
136
+ }
137
+ if (CSV_ESCAPE_RE.test(value)) {
138
+ return `"${value.replace(DOUBLE_QUOTE_RE, '""')}"`;
139
+ }
140
+ return value;
141
+ }
142
+
143
+ function getSubmissionPreview(form: FormDefinition, sub: Submission): string {
144
+ const fields = getFormFields(form).filter((f) => f.type !== "hidden" && f.type !== "file");
145
+ const previews: string[] = [];
146
+ for (const field of fields.slice(0, 3)) {
147
+ const v = sub.data[field.name];
148
+ if (v !== undefined && v !== null && v !== "") {
149
+ const str = String(v as string | number | boolean);
150
+ previews.push(str.length > 50 ? `${str.slice(0, 47)}...` : str);
151
+ }
152
+ }
153
+ return previews.join(" | ") || "(empty)";
154
+ }
155
+
156
+ function formatBytes(bytes: number): string {
157
+ if (bytes < 1024) return `${bytes} B`;
158
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
159
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
160
+ }