@idirdev/formguard 1.0.0

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.
package/src/react.js ADDED
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * React hook for real-time form validation with FormGuard.
5
+ *
6
+ * Usage:
7
+ * import { useFormGuard } from '@idirdev/formguard/react';
8
+ *
9
+ * function LoginForm() {
10
+ * const { values, errors, touched, handleChange, handleBlur, handleSubmit, isValid, reset } =
11
+ * useFormGuard({
12
+ * initialValues: { email: '', password: '' },
13
+ * rules: {
14
+ * email: ['required', 'email'],
15
+ * password: ['required', { rule: 'minLength', param: 8 }]
16
+ * },
17
+ * onSubmit: (data) => { console.log(data); }
18
+ * });
19
+ *
20
+ * return (
21
+ * <form onSubmit={handleSubmit}>
22
+ * <input name="email" value={values.email} onChange={handleChange} onBlur={handleBlur} />
23
+ * {touched.email && errors.email && <span>{errors.email[0]}</span>}
24
+ * ...
25
+ * </form>
26
+ * );
27
+ * }
28
+ */
29
+
30
+ let React;
31
+ try {
32
+ React = require('react');
33
+ } catch (e) {
34
+ // React not available; exports will throw clear errors when called
35
+ }
36
+
37
+ const { validate } = require('./validator');
38
+
39
+ /**
40
+ * @param {Object} config
41
+ * @param {Object} config.initialValues - Initial form values
42
+ * @param {Object} config.rules - Validation rules
43
+ * @param {Function} [config.onSubmit] - Submission handler (receives validated data)
44
+ * @param {string} [config.locale='en'] - Locale for messages
45
+ * @param {Object} [config.messages] - Custom messages
46
+ * @param {Object} [config.labels] - Human-readable field labels
47
+ * @param {boolean} [config.validateOnChange=false] - Validate on every change
48
+ * @param {boolean} [config.validateOnBlur=true] - Validate on blur
49
+ */
50
+ function useFormGuard(config) {
51
+ if (!React) {
52
+ throw new Error('FormGuard: React is required for useFormGuard. Install react as a dependency.');
53
+ }
54
+
55
+ const { useState, useCallback, useRef, useMemo } = React;
56
+
57
+ const {
58
+ initialValues = {},
59
+ rules: validationRules = {},
60
+ onSubmit,
61
+ locale = 'en',
62
+ messages: customMessages,
63
+ labels,
64
+ validateOnChange = false,
65
+ validateOnBlur = true
66
+ } = config;
67
+
68
+ const [values, setValues] = useState({ ...initialValues });
69
+ const [errors, setErrors] = useState({});
70
+ const [touched, setTouched] = useState({});
71
+ const [isSubmitting, setIsSubmitting] = useState(false);
72
+ const [submitCount, setSubmitCount] = useState(0);
73
+
74
+ const rulesRef = useRef(validationRules);
75
+ rulesRef.current = validationRules;
76
+
77
+ const validateOptions = useMemo(() => ({
78
+ locale,
79
+ messages: customMessages,
80
+ labels
81
+ }), [locale, customMessages, labels]);
82
+
83
+ /**
84
+ * Validate a single field.
85
+ */
86
+ const validateField = useCallback((fieldName, fieldValue, allValues) => {
87
+ if (!rulesRef.current[fieldName]) return [];
88
+
89
+ const result = validate(
90
+ { ...allValues, [fieldName]: fieldValue },
91
+ { [fieldName]: rulesRef.current[fieldName] },
92
+ validateOptions
93
+ );
94
+
95
+ return result.errors[fieldName] || [];
96
+ }, [validateOptions]);
97
+
98
+ /**
99
+ * Validate all fields.
100
+ */
101
+ const validateAll = useCallback((data) => {
102
+ return validate(data || values, rulesRef.current, validateOptions);
103
+ }, [values, validateOptions]);
104
+
105
+ /**
106
+ * Handle input change events.
107
+ */
108
+ const handleChange = useCallback((e) => {
109
+ const { name, value, type, checked } = e.target;
110
+ const newValue = type === 'checkbox' ? checked : value;
111
+
112
+ setValues(prev => {
113
+ const next = { ...prev, [name]: newValue };
114
+
115
+ if (validateOnChange || submitCount > 0) {
116
+ const fieldErrors = validateField(name, newValue, next);
117
+ setErrors(prevErrors => {
118
+ const updated = { ...prevErrors };
119
+ if (fieldErrors.length > 0) {
120
+ updated[name] = fieldErrors;
121
+ } else {
122
+ delete updated[name];
123
+ }
124
+ return updated;
125
+ });
126
+ }
127
+
128
+ return next;
129
+ });
130
+ }, [validateOnChange, validateField, submitCount]);
131
+
132
+ /**
133
+ * Handle blur events (triggers field validation).
134
+ */
135
+ const handleBlur = useCallback((e) => {
136
+ const { name } = e.target;
137
+
138
+ setTouched(prev => ({ ...prev, [name]: true }));
139
+
140
+ if (validateOnBlur) {
141
+ const fieldErrors = validateField(name, values[name], values);
142
+ setErrors(prev => {
143
+ const updated = { ...prev };
144
+ if (fieldErrors.length > 0) {
145
+ updated[name] = fieldErrors;
146
+ } else {
147
+ delete updated[name];
148
+ }
149
+ return updated;
150
+ });
151
+ }
152
+ }, [validateOnBlur, validateField, values]);
153
+
154
+ /**
155
+ * Set a single field value programmatically.
156
+ */
157
+ const setFieldValue = useCallback((name, value) => {
158
+ setValues(prev => ({ ...prev, [name]: value }));
159
+ }, []);
160
+
161
+ /**
162
+ * Set a field error programmatically.
163
+ */
164
+ const setFieldError = useCallback((name, error) => {
165
+ setErrors(prev => ({
166
+ ...prev,
167
+ [name]: Array.isArray(error) ? error : [error]
168
+ }));
169
+ }, []);
170
+
171
+ /**
172
+ * Handle form submission.
173
+ */
174
+ const handleSubmit = useCallback((e) => {
175
+ if (e && e.preventDefault) e.preventDefault();
176
+
177
+ setSubmitCount(c => c + 1);
178
+
179
+ // Mark all fields as touched
180
+ const allTouched = {};
181
+ for (const key of Object.keys(rulesRef.current)) {
182
+ allTouched[key] = true;
183
+ }
184
+ setTouched(prev => ({ ...prev, ...allTouched }));
185
+
186
+ const result = validateAll(values);
187
+ setErrors(result.errors);
188
+
189
+ if (!result.valid) return;
190
+
191
+ if (onSubmit) {
192
+ setIsSubmitting(true);
193
+ const maybePromise = onSubmit(values);
194
+ if (maybePromise && typeof maybePromise.then === 'function') {
195
+ maybePromise
196
+ .catch(() => {})
197
+ .finally(() => setIsSubmitting(false));
198
+ } else {
199
+ setIsSubmitting(false);
200
+ }
201
+ }
202
+ }, [values, validateAll, onSubmit]);
203
+
204
+ /**
205
+ * Reset form to initial state.
206
+ */
207
+ const reset = useCallback((newValues) => {
208
+ setValues(newValues || { ...initialValues });
209
+ setErrors({});
210
+ setTouched({});
211
+ setIsSubmitting(false);
212
+ setSubmitCount(0);
213
+ }, [initialValues]);
214
+
215
+ const isValid = useMemo(() => Object.keys(errors).length === 0, [errors]);
216
+
217
+ const isDirty = useMemo(() => {
218
+ return Object.keys(values).some(key => values[key] !== initialValues[key]);
219
+ }, [values, initialValues]);
220
+
221
+ return {
222
+ values,
223
+ errors,
224
+ touched,
225
+ isValid,
226
+ isDirty,
227
+ isSubmitting,
228
+ submitCount,
229
+ handleChange,
230
+ handleBlur,
231
+ handleSubmit,
232
+ setFieldValue,
233
+ setFieldError,
234
+ setValues,
235
+ setErrors,
236
+ validateAll,
237
+ validateField,
238
+ reset
239
+ };
240
+ }
241
+
242
+ module.exports = { useFormGuard };
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ const ENT = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#x27;', '/': '&#x2F;' };
3
+ function escapeHtml(s) { return String(s).replace(/[&<>"'/]/g, c => ENT[c]); }
4
+ function stripTags(s, a = []) { if (!a.length) return String(s).replace(/<[^>]*>/g, ''); const t = a.map(x => x.toLowerCase()); return String(s).replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g, (m, x) => t.includes(x.toLowerCase()) ? m : ''); }
5
+ function slugify(s) { return String(s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
6
+ function normalizeEmail(e) { const [l, d] = String(e).trim().toLowerCase().split('@'); if (!l || !d) return e; let n = l.replace(/\+.*$/, ''); if (['gmail.com','googlemail.com'].includes(d)) n = n.replace(/\./g, ''); return n + '@' + d; }
7
+ function trim(s) { return String(s).trim(); }
8
+ function toLowerCase(s) { return String(s).toLowerCase(); }
9
+ function toNumber(s) { const n = Number(s); return isNaN(n) ? 0 : n; }
10
+ function toBoolean(s) { return ['true','1','yes','on'].includes(String(s).toLowerCase()); }
11
+ function truncate(s, l = 100) { s = String(s); return s.length > l ? s.slice(0, l) + '...' : s; }
12
+ const SANITIZERS = { escapeHtml, stripTags, slugify, normalizeEmail, trim, toLowerCase, toNumber, toBoolean, truncate };
13
+ function sanitize(data, rules) { const r = { ...data }; for (const [f, fns] of Object.entries(rules)) { if (r[f] === undefined) continue; const list = typeof fns === 'string' ? fns.split('|') : fns; for (const fn of list) { const s = typeof fn === 'function' ? fn : SANITIZERS[typeof fn === 'string' ? fn.trim() : fn]; if (s) r[f] = s(r[f]); } } return r; }
14
+ module.exports = { sanitize, escapeHtml, stripTags, slugify, normalizeEmail, trim, toLowerCase, toNumber, toBoolean, truncate, SANITIZERS };
package/src/schema.js ADDED
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+ const { validate: run } = require('./validators');
3
+ class Schema {
4
+ constructor() { this._f = {}; this._c = null; }
5
+ field(n) { this._c = n; this._f[n] = []; return this; }
6
+ required() { this._f[this._c].push({ name: 'required', params: [] }); return this; }
7
+ email() { this._f[this._c].push({ name: 'email', params: [] }); return this; }
8
+ minLength(n) { this._f[this._c].push({ name: 'minLength', params: [n] }); return this; }
9
+ maxLength(n) { this._f[this._c].push({ name: 'maxLength', params: [n] }); return this; }
10
+ min(n) { this._f[this._c].push({ name: 'min', params: [n] }); return this; }
11
+ max(n) { this._f[this._c].push({ name: 'max', params: [n] }); return this; }
12
+ integer() { this._f[this._c].push({ name: 'integer', params: [] }); return this; }
13
+ strongPassword() { this._f[this._c].push({ name: 'strongPassword', params: [] }); return this; }
14
+ validate(data, opts) { return run(data, this._f, opts); }
15
+ }
16
+ module.exports = { createSchema: () => new Schema(), Schema };
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ const builtInRules = require('./rules');
4
+ const { getMessage } = require('./messages');
5
+
6
+ /**
7
+ * Parse a rule definition into a normalized structure.
8
+ * Supports multiple formats:
9
+ * - String: 'required'
10
+ * - String with params: 'minLength:6'
11
+ * - Function: (value) => boolean
12
+ * - Object: { rule: 'email', message: 'Bad email' }
13
+ * - Array: ['required', 'email', { rule: 'minLength', param: 6 }]
14
+ */
15
+ function parseRuleDefinition(def) {
16
+ if (typeof def === 'string') {
17
+ const [name, ...paramParts] = def.split(':');
18
+ const param = paramParts.length > 0 ? paramParts.join(':') : undefined;
19
+ return [{ name, param }];
20
+ }
21
+
22
+ if (typeof def === 'function') {
23
+ return [{ name: 'custom', fn: def }];
24
+ }
25
+
26
+ if (Array.isArray(def)) {
27
+ const results = [];
28
+ for (const item of def) {
29
+ results.push(...parseRuleDefinition(item));
30
+ }
31
+ return results;
32
+ }
33
+
34
+ if (typeof def === 'object' && def !== null) {
35
+ return [{
36
+ name: def.rule || 'custom',
37
+ param: def.param,
38
+ message: def.message,
39
+ fn: def.fn || def.validate
40
+ }];
41
+ }
42
+
43
+ return [];
44
+ }
45
+
46
+ /**
47
+ * Resolve a parsed rule definition into an executable validation function.
48
+ */
49
+ function resolveRule(parsed) {
50
+ if (parsed.fn) {
51
+ const fn = typeof parsed.fn === 'function' ? parsed.fn : null;
52
+ return { validate: fn, name: parsed.name, param: parsed.param, message: parsed.message, needsData: true };
53
+ }
54
+
55
+ const rule = builtInRules[parsed.name];
56
+ if (!rule) {
57
+ throw new Error(`FormGuard: Unknown rule "${parsed.name}". Register it or use a custom function.`);
58
+ }
59
+
60
+ // Rules that take parameters return a function
61
+ const parameterizedRules = ['min', 'max', 'minLength', 'maxLength', 'pattern', 'before', 'after', 'in', 'enum', 'notIn', 'equals', 'confirmed', 'custom'];
62
+
63
+ if (parameterizedRules.includes(parsed.name) && typeof rule === 'function') {
64
+ let param = parsed.param;
65
+ // Auto-parse numeric params for min/max/minLength/maxLength
66
+ if (['min', 'max', 'minLength', 'maxLength'].includes(parsed.name) && typeof param === 'string') {
67
+ param = Number(param);
68
+ }
69
+ // Parse array params for in/notIn/enum
70
+ if (['in', 'notIn', 'enum'].includes(parsed.name) && typeof param === 'string') {
71
+ param = param.split(',').map(s => s.trim());
72
+ }
73
+
74
+ const validatorFn = rule(param);
75
+ return {
76
+ validate: validatorFn,
77
+ name: parsed.name,
78
+ param: param,
79
+ message: parsed.message,
80
+ needsData: validatorFn._needsData || false
81
+ };
82
+ }
83
+
84
+ return {
85
+ validate: rule,
86
+ name: parsed.name,
87
+ param: parsed.param,
88
+ message: parsed.message,
89
+ needsData: rule._needsData || false
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Validate a data object against a set of rules.
95
+ *
96
+ * @param {Object} data - The data object to validate
97
+ * @param {Object} rules - Validation rules keyed by field name
98
+ * @param {Object} [options] - Options
99
+ * @param {string} [options.locale='en'] - Locale for error messages ('en' or 'fr')
100
+ * @param {Object} [options.messages] - Custom error message overrides
101
+ * @param {boolean} [options.abortEarly=false] - Stop on first error
102
+ * @param {Object} [options.labels] - Human-readable field labels { fieldName: 'Label' }
103
+ * @returns {{ valid: boolean, errors: Object<string, string[]> }}
104
+ */
105
+ function validate(data, rules, options = {}) {
106
+ const {
107
+ locale = 'en',
108
+ messages: customMessages = {},
109
+ abortEarly = false,
110
+ labels = {}
111
+ } = options;
112
+
113
+ const errors = {};
114
+ const dataObj = data || {};
115
+
116
+ for (const [field, ruleDefs] of Object.entries(rules)) {
117
+ const fieldLabel = labels[field] || field;
118
+ const value = getNestedValue(dataObj, field);
119
+ const parsedRules = parseRuleDefinition(ruleDefs);
120
+ const fieldErrors = [];
121
+
122
+ for (const parsed of parsedRules) {
123
+ const resolved = resolveRule(parsed);
124
+
125
+ if (!resolved.validate) continue;
126
+
127
+ let isValid;
128
+ if (resolved.needsData) {
129
+ isValid = resolved.validate(value, field, dataObj);
130
+ } else {
131
+ isValid = resolved.validate(value);
132
+ }
133
+
134
+ if (!isValid) {
135
+ const msg = resolved.message ||
136
+ getMessage(resolved.name, fieldLabel, resolved.param, locale, customMessages);
137
+ fieldErrors.push(msg);
138
+
139
+ if (abortEarly) break;
140
+ }
141
+ }
142
+
143
+ if (fieldErrors.length > 0) {
144
+ errors[field] = fieldErrors;
145
+ if (abortEarly) break;
146
+ }
147
+ }
148
+
149
+ return {
150
+ valid: Object.keys(errors).length === 0,
151
+ errors
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Get a nested value from an object using dot notation.
157
+ * Supports array indexing: 'items.0.name'
158
+ */
159
+ function getNestedValue(obj, path) {
160
+ if (!path.includes('.')) return obj[path];
161
+ return path.split('.').reduce((current, key) => {
162
+ if (current === undefined || current === null) return undefined;
163
+ return current[key];
164
+ }, obj);
165
+ }
166
+
167
+ /**
168
+ * Async version of validate for rules that return promises.
169
+ */
170
+ async function validateAsync(data, rules, options = {}) {
171
+ const {
172
+ locale = 'en',
173
+ messages: customMessages = {},
174
+ abortEarly = false,
175
+ labels = {}
176
+ } = options;
177
+
178
+ const errors = {};
179
+ const dataObj = data || {};
180
+
181
+ for (const [field, ruleDefs] of Object.entries(rules)) {
182
+ const fieldLabel = labels[field] || field;
183
+ const value = getNestedValue(dataObj, field);
184
+ const parsedRules = parseRuleDefinition(ruleDefs);
185
+ const fieldErrors = [];
186
+
187
+ for (const parsed of parsedRules) {
188
+ const resolved = resolveRule(parsed);
189
+ if (!resolved.validate) continue;
190
+
191
+ let isValid;
192
+ if (resolved.needsData) {
193
+ isValid = await resolved.validate(value, field, dataObj);
194
+ } else {
195
+ isValid = await resolved.validate(value);
196
+ }
197
+
198
+ if (!isValid) {
199
+ const msg = resolved.message ||
200
+ getMessage(resolved.name, fieldLabel, resolved.param, locale, customMessages);
201
+ fieldErrors.push(msg);
202
+ if (abortEarly) break;
203
+ }
204
+ }
205
+
206
+ if (fieldErrors.length > 0) {
207
+ errors[field] = fieldErrors;
208
+ if (abortEarly) break;
209
+ }
210
+ }
211
+
212
+ return {
213
+ valid: Object.keys(errors).length === 0,
214
+ errors
215
+ };
216
+ }
217
+
218
+ module.exports = { validate, validateAsync, parseRuleDefinition, resolveRule, getNestedValue };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+ const { getMessage } = require('./messages');
3
+ const RULES = {
4
+ required: (v) => v !== undefined && v !== null && v !== '',
5
+ email: (v) => /^[^@s]+@[^@s]+.[^@s]+$/.test(v),
6
+ url: (v) => { try { new URL(v); return true; } catch { return false; } },
7
+ minLength: (v, n) => typeof v === 'string' && v.length >= n,
8
+ maxLength: (v, n) => typeof v === 'string' && v.length <= n,
9
+ min: (v, n) => Number(v) >= n,
10
+ max: (v, n) => Number(v) <= n,
11
+ integer: (v) => Number.isInteger(Number(v)) && !isNaN(v),
12
+ numeric: (v) => !isNaN(v) && !isNaN(parseFloat(v)),
13
+ alpha: (v) => /^[a-zA-Z]+$/.test(v),
14
+ alphanumeric: (v) => /^[a-zA-Z0-9]+$/.test(v),
15
+ phone: (v) => /^\+?[0-9\s\-().]{7,20}$/.test(v),
16
+ creditCard: (v) => {
17
+ const d = String(v).replace(/\D/g, '');
18
+ if (d.length < 13 || d.length > 19) return false;
19
+ let sum = 0, alt = false;
20
+ for (let i = d.length - 1; i >= 0; i--) {
21
+ let n = parseInt(d[i], 10);
22
+ if (alt) { n *= 2; if (n > 9) n -= 9; }
23
+ sum += n; alt = !alt;
24
+ }
25
+ return sum % 10 === 0;
26
+ },
27
+ uuid: (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v),
28
+ ip: (v) => /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/.test(v),
29
+ date: (v) => !isNaN(Date.parse(v)),
30
+ slug: (v) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v),
31
+ hex: (v) => /^[0-9a-fA-F]+$/.test(v),
32
+ json: (v) => { try { JSON.parse(v); return true; } catch { return false; } },
33
+ equals: (v, expected) => v === expected,
34
+ oneOf: (v, list) => (Array.isArray(list) ? list : list.split(',')).includes(v),
35
+ matches: (v, pattern) => new RegExp(pattern).test(v),
36
+ strongPassword: (v) => typeof v === 'string' && v.length >= 8 && /[a-z]/.test(v) && /[A-Z]/.test(v) && /[0-9]/.test(v) && /[^a-zA-Z0-9]/.test(v),
37
+ };
38
+ function validate(data, rules, opts = {}) {
39
+ const errors = {};
40
+ for (const [field, fr] of Object.entries(rules)) {
41
+ const value = field.split('.').reduce((o, k) => o && o[k], data);
42
+ const rl = typeof fr === 'string' ? fr.split('|').map(parseRule) : fr;
43
+ const fe = validateField(value, rl, { ...opts, field });
44
+ if (fe.length > 0) { errors[field] = fe; if (opts.abortEarly) break; }
45
+ }
46
+ return { valid: Object.keys(errors).length === 0, errors };
47
+ }
48
+ function validateField(value, rules, opts = {}) {
49
+ const errors = [];
50
+ const rl = typeof rules === 'string' ? rules.split('|').map(parseRule) : rules;
51
+ for (const rule of rl) {
52
+ const { name, params } = typeof rule === 'string' ? parseRule(rule) : rule;
53
+ if (name === 'required') { if (!RULES.required(value)) errors.push(getMessage(name, opts.field, params)); continue; }
54
+ if (value === undefined || value === null || value === '') continue;
55
+ const v = RULES[name];
56
+ if (v && !v(value, ...params)) errors.push(getMessage(name, opts.field, params));
57
+ }
58
+ return errors;
59
+ }
60
+ function parseRule(s) { const [n, p] = s.split(':'); return { name: n.trim(), params: p ? p.split(',').map(x => isNaN(x) ? x.trim() : Number(x.trim())) : [] }; }
61
+ module.exports = { validate, validateField, RULES };