@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,205 @@
1
+ /**
2
+ * Server-side submission validation.
3
+ *
4
+ * Validates submitted data against the form's field definitions.
5
+ * These rules mirror what the client-side script checks, but server
6
+ * validation is authoritative — never trust the client.
7
+ */
8
+
9
+ import type { FieldType, FormField } from "./types.js";
10
+
11
+ export interface ValidationError {
12
+ field: string;
13
+ message: string;
14
+ }
15
+
16
+ export interface ValidationResult {
17
+ valid: boolean;
18
+ errors: ValidationError[];
19
+ /** Sanitized/coerced values */
20
+ data: Record<string, unknown>;
21
+ }
22
+
23
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
24
+ const URL_RE = /^https?:\/\/.+/;
25
+ const TEL_RE = /^[+\d][\d\s()-]*$/;
26
+
27
+ /**
28
+ * Validate submission data against form field definitions.
29
+ *
30
+ * Returns sanitized data with proper type coercion and all validation
31
+ * errors. Conditionally hidden fields are excluded from validation
32
+ * if their condition is not met.
33
+ */
34
+ export function validateSubmission(
35
+ fields: FormField[],
36
+ data: Record<string, unknown>,
37
+ ): ValidationResult {
38
+ const errors: ValidationError[] = [];
39
+ const validated: Record<string, unknown> = {};
40
+
41
+ for (const field of fields) {
42
+ // Skip conditionally hidden fields
43
+ if (field.condition && !evaluateCondition(field.condition, data)) {
44
+ continue;
45
+ }
46
+
47
+ const raw = data[field.name];
48
+ const value = typeof raw === "string" ? raw.trim() : raw;
49
+ const isEmpty = value === undefined || value === null || value === "";
50
+
51
+ // Required check
52
+ if (field.required && isEmpty) {
53
+ errors.push({ field: field.name, message: `${field.label} is required` });
54
+ continue;
55
+ }
56
+
57
+ // Skip further validation if empty and not required
58
+ if (isEmpty) {
59
+ continue;
60
+ }
61
+
62
+ // Type-specific validation
63
+ const typeError = validateFieldType(field, value);
64
+ if (typeError) {
65
+ errors.push({ field: field.name, message: typeError });
66
+ continue;
67
+ }
68
+
69
+ // Validation rules
70
+ const ruleErrors = validateFieldRules(field, value);
71
+ for (const msg of ruleErrors) {
72
+ errors.push({ field: field.name, message: msg });
73
+ }
74
+
75
+ if (ruleErrors.length === 0) {
76
+ validated[field.name] = coerceValue(field.type, value);
77
+ }
78
+ }
79
+
80
+ return { valid: errors.length === 0, errors, data: validated };
81
+ }
82
+
83
+ function validateFieldType(field: FormField, value: unknown): string | null {
84
+ if (typeof value !== "string" && field.type !== "checkbox" && field.type !== "number") {
85
+ return `${field.label} has an invalid value`;
86
+ }
87
+
88
+ const strValue = String(value);
89
+
90
+ switch (field.type) {
91
+ case "email":
92
+ if (!EMAIL_RE.test(strValue)) return `${field.label} must be a valid email address`;
93
+ break;
94
+ case "url":
95
+ if (!URL_RE.test(strValue)) return `${field.label} must be a valid URL`;
96
+ break;
97
+ case "tel":
98
+ if (!TEL_RE.test(strValue)) return `${field.label} must be a valid phone number`;
99
+ break;
100
+ case "number": {
101
+ const num = Number(value);
102
+ if (Number.isNaN(num)) return `${field.label} must be a number`;
103
+ break;
104
+ }
105
+ case "date":
106
+ if (Number.isNaN(Date.parse(strValue))) return `${field.label} must be a valid date`;
107
+ break;
108
+ case "select":
109
+ case "radio":
110
+ if (field.options && !field.options.some((o) => o.value === strValue)) {
111
+ return `${field.label} has an invalid selection`;
112
+ }
113
+ break;
114
+ case "checkbox-group": {
115
+ const values = Array.isArray(value) ? value : [value];
116
+ if (field.options) {
117
+ const validValues = new Set(field.options.map((o) => o.value));
118
+ for (const v of values) {
119
+ if (!validValues.has(String(v))) {
120
+ return `${field.label} contains an invalid selection`;
121
+ }
122
+ }
123
+ }
124
+ break;
125
+ }
126
+ }
127
+
128
+ return null;
129
+ }
130
+
131
+ function validateFieldRules(field: FormField, value: unknown): string[] {
132
+ const errors: string[] = [];
133
+ const v = field.validation;
134
+ if (!v) return errors;
135
+
136
+ const strValue = String(value);
137
+
138
+ if (v.minLength !== undefined && strValue.length < v.minLength) {
139
+ errors.push(`${field.label} must be at least ${v.minLength} characters`);
140
+ }
141
+ if (v.maxLength !== undefined && strValue.length > v.maxLength) {
142
+ errors.push(`${field.label} must be at most ${v.maxLength} characters`);
143
+ }
144
+
145
+ if (field.type === "number") {
146
+ const num = Number(value);
147
+ if (v.min !== undefined && num < v.min) {
148
+ errors.push(`${field.label} must be at least ${v.min}`);
149
+ }
150
+ if (v.max !== undefined && num > v.max) {
151
+ errors.push(`${field.label} must be at most ${v.max}`);
152
+ }
153
+ }
154
+
155
+ if (v.pattern) {
156
+ try {
157
+ const re = new RegExp(v.pattern);
158
+ if (!re.test(strValue)) {
159
+ errors.push(v.patternMessage || `${field.label} has an invalid format`);
160
+ }
161
+ } catch {
162
+ // Invalid regex in config — skip pattern check
163
+ }
164
+ }
165
+
166
+ return errors;
167
+ }
168
+
169
+ function coerceValue(type: FieldType, value: unknown): unknown {
170
+ switch (type) {
171
+ case "number":
172
+ return Number(value);
173
+ case "checkbox":
174
+ return value === "on" || value === "true" || value === true;
175
+ case "checkbox-group":
176
+ return Array.isArray(value) ? value : [value];
177
+ default:
178
+ return typeof value === "string" ? value.trim() : value;
179
+ }
180
+ }
181
+
182
+ function evaluateCondition(
183
+ condition: { field: string; op: string; value?: string },
184
+ data: Record<string, unknown>,
185
+ ): boolean {
186
+ const fieldValue = data[condition.field];
187
+ const strValue =
188
+ fieldValue === undefined || fieldValue === null
189
+ ? ""
190
+ : String(fieldValue as string | number | boolean);
191
+ const isFilled = strValue !== "";
192
+
193
+ switch (condition.op) {
194
+ case "eq":
195
+ return strValue === (condition.value ?? "");
196
+ case "neq":
197
+ return strValue !== (condition.value ?? "");
198
+ case "filled":
199
+ return isFilled;
200
+ case "empty":
201
+ return !isFilled;
202
+ default:
203
+ return true;
204
+ }
205
+ }