@africode/core 5.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/AFRICODE_FRAMEWORK_GUIDE.md +707 -0
- package/LICENSE +623 -0
- package/README.md +442 -0
- package/bin/africode.js +73 -0
- package/bin/africode.js.1758507140 +343 -0
- package/bin/cli.ts +83 -0
- package/bin/create-africode.js +158 -0
- package/bin/scaffold.ts +219 -0
- package/components/accordion.js +183 -0
- package/components/alert.js +131 -0
- package/components/auth.js +172 -0
- package/components/avatar.js +117 -0
- package/components/badge.js +104 -0
- package/components/base.d.ts +139 -0
- package/components/base.js +184 -0
- package/components/button.js +164 -0
- package/components/card.js +137 -0
- package/components/cultural-card.js +243 -0
- package/components/divider.js +83 -0
- package/components/dropdown.js +171 -0
- package/components/error-boundary.js +155 -0
- package/components/form.js +131 -0
- package/components/grid.js +273 -0
- package/components/hero.js +138 -0
- package/components/icon.js +36 -0
- package/components/index.js +57 -0
- package/components/input.js +256 -0
- package/components/kanga-card.js +185 -0
- package/components/language-switcher.js +108 -0
- package/components/loader.js +80 -0
- package/components/modal.js +262 -0
- package/components/motion.js +84 -0
- package/components/navbar.js +236 -0
- package/components/pattern-showcase.js +225 -0
- package/components/progress.js +134 -0
- package/components/react.js +111 -0
- package/components/section.js +54 -0
- package/components/select.js +322 -0
- package/components/sidebar.js +180 -0
- package/components/skeleton.js +85 -0
- package/components/table.js +181 -0
- package/components/tabs.js +202 -0
- package/components/theme-toggle.js +82 -0
- package/components/toast.js +139 -0
- package/components/tooltip.js +167 -0
- package/core/a2ui-schema-manager.js +344 -0
- package/core/a2ui.js +431 -0
- package/core/bun-runtime.js +799 -0
- package/core/cli/commands/add.js +23 -0
- package/core/cli/commands/audit.js +58 -0
- package/core/cli/commands/build.js +137 -0
- package/core/cli/commands/create-plugin.js +241 -0
- package/core/cli/commands/dev.js +228 -0
- package/core/cli/commands/lint.js +23 -0
- package/core/cli/commands/test.js +34 -0
- package/core/cli/migrator.js +71 -0
- package/core/cli/ui.js +46 -0
- package/core/compliance.js +628 -0
- package/core/config.js +263 -0
- package/core/db-advanced.js +481 -0
- package/core/db.js +284 -0
- package/core/enhanced-hmr.js +404 -0
- package/core/errors.js +222 -0
- package/core/file-router.js +290 -0
- package/core/heartbeat.js +64 -0
- package/core/hmr-client.js +204 -0
- package/core/hmr.js +196 -0
- package/core/html.d.ts +116 -0
- package/core/html.js +160 -0
- package/core/hydration.js +52 -0
- package/core/lipa-namba-journey.js +572 -0
- package/core/motion.js +106 -0
- package/core/nida-cig-middleware.js +455 -0
- package/core/patterns.d.ts +124 -0
- package/core/patterns.js +833 -0
- package/core/plugins/index.js +312 -0
- package/core/router.js +387 -0
- package/core/sdk-client.js +62 -0
- package/core/sdk.d.ts +133 -0
- package/core/sdk.js +123 -0
- package/core/seo.js +76 -0
- package/core/server/auth-endpoints.js +339 -0
- package/core/server/auth.js +180 -0
- package/core/server/csrf.js +206 -0
- package/core/server/db.js +39 -0
- package/core/server/middleware.js +324 -0
- package/core/server/rate-limit.js +238 -0
- package/core/server/render.js +69 -0
- package/core/server/router.js +120 -0
- package/core/shim.js +28 -0
- package/core/state.d.ts +86 -0
- package/core/state.js +242 -0
- package/core/store.d.ts +122 -0
- package/core/store.js +61 -0
- package/core/validation.d.ts +233 -0
- package/core/validation.js +590 -0
- package/core/websocket.js +639 -0
- package/dist/africode.js +2905 -0
- package/dist/africode.js.map +61 -0
- package/dist/build-info.json +23 -0
- package/dist/components.js +2888 -0
- package/dist/components.js.map +58 -0
- package/dist/styles/africanity.css +322 -0
- package/dist/styles/typography.css +141 -0
- package/docs/IDE-Guide.md +50 -0
- package/package.json +110 -0
- package/src/index.ts +196 -0
- package/styles/africanity.css +322 -0
- package/styles/typography.css +141 -0
- package/templates/starter/.env.example +15 -0
- package/templates/starter/africode.config.js +40 -0
- package/templates/starter/package.json +14 -0
- package/templates/starter/src/pages/index.html +46 -0
- package/templates/starter/src/pages/index.js +32 -0
- package/templates/starter/src/styles/main.css +4 -0
- package/templates/starter-3d/.env.example +7 -0
- package/templates/starter-3d/africode.config.js +29 -0
- package/templates/starter-3d/components/af-model-viewer.js +125 -0
- package/templates/starter-3d/package.json +15 -0
- package/templates/starter-3d/src/pages/index.html +46 -0
- package/templates/starter-3d/src/pages/index.js +50 -0
- package/templates/starter-3d/src/styles/main.css +4 -0
- package/templates/starter-react/.env.example +15 -0
- package/templates/starter-react/africode.config.js +40 -0
- package/templates/starter-react/package.json +16 -0
- package/templates/starter-react/src/pages/index.html +46 -0
- package/templates/starter-react/src/pages/index.js +68 -0
- package/templates/starter-react/src/styles/main.css +4 -0
- package/templates/starter-tailwind/.env.example +15 -0
- package/templates/starter-tailwind/africode.config.js +40 -0
- package/templates/starter-tailwind/package.json +20 -0
- package/templates/starter-tailwind/src/pages/index.html +46 -0
- package/templates/starter-tailwind/src/pages/index.js +37 -0
- package/templates/starter-tailwind/src/styles/main.css +4 -0
- package/templates/starter-tailwind/src/styles/tailwind.css +1 -0
- package/templates/starter-tailwind/src/tailwind-loader.js +30 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AfriCode Validation System
|
|
3
|
+
*
|
|
4
|
+
* Philosophy: Strict by default. Convenient by composition. Secure early.
|
|
5
|
+
*
|
|
6
|
+
* Core validation is strict — URL fields accept only valid URLs.
|
|
7
|
+
* Normalization (e.g. empty string → null) is explicit and opt-in.
|
|
8
|
+
* Security basics (CSRF, rate limiting) are built into the foundation.
|
|
9
|
+
*
|
|
10
|
+
* Provides:
|
|
11
|
+
* - Zod-based validation schemas for forms, auth, and API validation
|
|
12
|
+
* - AfriFieldBuilder: chainable field builder with .nullable(), .emptyAsNull(), .optional()
|
|
13
|
+
* - afri namespace: factory for building field schemas (afri.url(), afri.string(), etc.)
|
|
14
|
+
* - normalizeInput(): preprocessing utility that runs before validation
|
|
15
|
+
*
|
|
16
|
+
* @module core/validation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { getConfig } from './config.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Common validation schemas for AfriCode components
|
|
24
|
+
*/
|
|
25
|
+
export const schemas = {
|
|
26
|
+
// User authentication schemas
|
|
27
|
+
login: z.object({
|
|
28
|
+
email: z.string()
|
|
29
|
+
.min(1, 'Email is required')
|
|
30
|
+
.email('Please enter a valid email address'),
|
|
31
|
+
password: z.string()
|
|
32
|
+
.min(8, 'Password must be at least 8 characters')
|
|
33
|
+
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
|
34
|
+
'Password must contain at least one uppercase letter, one lowercase letter, and one number')
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
register: z.object({
|
|
38
|
+
name: z.string()
|
|
39
|
+
.min(2, 'Name must be at least 2 characters')
|
|
40
|
+
.max(50, 'Name must be less than 50 characters')
|
|
41
|
+
.regex(/^[a-zA-Z\s'-]+$/, 'Name can only contain letters, spaces, hyphens, and apostrophes'),
|
|
42
|
+
email: z.string()
|
|
43
|
+
.min(1, 'Email is required')
|
|
44
|
+
.email('Please enter a valid email address'),
|
|
45
|
+
password: z.string()
|
|
46
|
+
.min(8, 'Password must be at least 8 characters')
|
|
47
|
+
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/,
|
|
48
|
+
'Password must contain uppercase, lowercase, number, and special character'),
|
|
49
|
+
confirmPassword: z.string()
|
|
50
|
+
}).refine(data => data.password === data.confirmPassword, {
|
|
51
|
+
message: "Passwords don't match",
|
|
52
|
+
path: ["confirmPassword"]
|
|
53
|
+
}),
|
|
54
|
+
|
|
55
|
+
auth: {
|
|
56
|
+
register: z.object({
|
|
57
|
+
action: z.literal('register'),
|
|
58
|
+
email: z.string()
|
|
59
|
+
.min(1, 'Email is required')
|
|
60
|
+
.email('Please enter a valid email address'),
|
|
61
|
+
username: z.string()
|
|
62
|
+
.min(3, 'Username must be at least 3 characters')
|
|
63
|
+
.max(30, 'Username must be less than 30 characters'),
|
|
64
|
+
password: z.string()
|
|
65
|
+
.min(8, 'Password must be at least 8 characters'),
|
|
66
|
+
full_name: z.string()
|
|
67
|
+
.min(2, 'Full name is required')
|
|
68
|
+
.max(100, 'Full name is too long')
|
|
69
|
+
}),
|
|
70
|
+
|
|
71
|
+
login: z.object({
|
|
72
|
+
action: z.literal('login'),
|
|
73
|
+
email: z.string()
|
|
74
|
+
.min(1, 'Email is required')
|
|
75
|
+
.email('Please enter a valid email address'),
|
|
76
|
+
password: z.string()
|
|
77
|
+
.min(8, 'Password must be at least 8 characters')
|
|
78
|
+
}),
|
|
79
|
+
|
|
80
|
+
logout: z.object({
|
|
81
|
+
action: z.literal('logout')
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
action: z.discriminatedUnion('action', [
|
|
85
|
+
z.object({ action: z.literal('register') }).merge(z.object({
|
|
86
|
+
email: z.string()
|
|
87
|
+
.min(1, 'Email is required')
|
|
88
|
+
.email('Please enter a valid email address'),
|
|
89
|
+
username: z.string()
|
|
90
|
+
.min(3, 'Username must be at least 3 characters')
|
|
91
|
+
.max(30, 'Username must be less than 30 characters'),
|
|
92
|
+
password: z.string()
|
|
93
|
+
.min(8, 'Password must be at least 8 characters'),
|
|
94
|
+
full_name: z.string()
|
|
95
|
+
.min(2, 'Full name is required')
|
|
96
|
+
.max(100, 'Full name is too long')
|
|
97
|
+
})),
|
|
98
|
+
z.object({ action: z.literal('login') }).merge(z.object({
|
|
99
|
+
email: z.string()
|
|
100
|
+
.min(1, 'Email is required')
|
|
101
|
+
.email('Please enter a valid email address'),
|
|
102
|
+
password: z.string()
|
|
103
|
+
.min(8, 'Password must be at least 8 characters')
|
|
104
|
+
})),
|
|
105
|
+
z.object({ action: z.literal('logout') })
|
|
106
|
+
])
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// Form input schemas
|
|
110
|
+
contact: z.object({
|
|
111
|
+
name: z.string()
|
|
112
|
+
.min(2, 'Name must be at least 2 characters')
|
|
113
|
+
.max(100, 'Name is too long'),
|
|
114
|
+
email: z.string()
|
|
115
|
+
.email('Please enter a valid email address'),
|
|
116
|
+
message: z.string()
|
|
117
|
+
.min(10, 'Message must be at least 10 characters')
|
|
118
|
+
.max(1000, 'Message is too long')
|
|
119
|
+
}),
|
|
120
|
+
|
|
121
|
+
// Generic input validation
|
|
122
|
+
email: z.string()
|
|
123
|
+
.min(1, 'Email is required')
|
|
124
|
+
.email('Please enter a valid email address'),
|
|
125
|
+
|
|
126
|
+
password: z.string()
|
|
127
|
+
.min(8, 'Password must be at least 8 characters')
|
|
128
|
+
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
|
129
|
+
'Password must contain uppercase, lowercase, and number'),
|
|
130
|
+
|
|
131
|
+
phone: z.string()
|
|
132
|
+
.regex(/^[\+]?[1-9][\d]{0,15}$/, 'Please enter a valid phone number'),
|
|
133
|
+
|
|
134
|
+
// Strict URL: must be a valid URL. Use afri.url().nullable() for nullable URLs.
|
|
135
|
+
url: z.string()
|
|
136
|
+
.min(1, 'URL is required')
|
|
137
|
+
.url('Please enter a valid URL'),
|
|
138
|
+
|
|
139
|
+
required: z.string()
|
|
140
|
+
.min(1, 'This field is required'),
|
|
141
|
+
|
|
142
|
+
// Number validations
|
|
143
|
+
positiveNumber: z.number()
|
|
144
|
+
.positive('Must be a positive number'),
|
|
145
|
+
|
|
146
|
+
integer: z.number()
|
|
147
|
+
.int('Must be a whole number'),
|
|
148
|
+
|
|
149
|
+
// Date validations
|
|
150
|
+
futureDate: z.date()
|
|
151
|
+
.min(new Date(), 'Date must be in the future'),
|
|
152
|
+
|
|
153
|
+
// File validations
|
|
154
|
+
imageFile: z.instanceof(File)
|
|
155
|
+
.refine(file => file.size <= 5 * 1024 * 1024, 'File size must be less than 5MB')
|
|
156
|
+
.refine(file => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type),
|
|
157
|
+
'File must be a JPEG, PNG, GIF, or WebP image'),
|
|
158
|
+
|
|
159
|
+
// --- DB Model Schemas ---
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* User profile update schema.
|
|
163
|
+
* avatar_url: valid URL or null (with opt-in empty-string normalization)
|
|
164
|
+
*/
|
|
165
|
+
userProfile: z.object({
|
|
166
|
+
full_name: z.string().min(2, 'Full name must be at least 2 characters').max(100, 'Full name is too long').optional(),
|
|
167
|
+
bio: z.string().max(500, 'Bio must be less than 500 characters').nullable().optional(),
|
|
168
|
+
avatar_url: z.string().url('Please enter a valid URL').nullable().optional(),
|
|
169
|
+
theme: z.enum(['africanity', 'dark', 'light', 'system']).optional(),
|
|
170
|
+
language: z.string().min(2).max(10).optional()
|
|
171
|
+
}),
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Project creation/update schema.
|
|
175
|
+
* repository_url, demo_url: valid URL or null
|
|
176
|
+
*/
|
|
177
|
+
project: z.object({
|
|
178
|
+
name: z.string().min(1, 'Project name is required').max(100, 'Project name is too long'),
|
|
179
|
+
description: z.string().max(1000, 'Description is too long').nullable().optional(),
|
|
180
|
+
repository_url: z.string().url('Please enter a valid repository URL').nullable().optional(),
|
|
181
|
+
demo_url: z.string().url('Please enter a valid demo URL').nullable().optional(),
|
|
182
|
+
tags: z.array(z.string()).optional(),
|
|
183
|
+
is_public: z.boolean().optional()
|
|
184
|
+
})
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Validation utilities
|
|
189
|
+
*/
|
|
190
|
+
export class Validation {
|
|
191
|
+
/**
|
|
192
|
+
* Validate data against a schema
|
|
193
|
+
* @param {z.ZodSchema} schema - Zod schema to validate against
|
|
194
|
+
* @param {any} data - Data to validate
|
|
195
|
+
* @returns {Object} { success: boolean, data?: any, errors?: Object }
|
|
196
|
+
*/
|
|
197
|
+
static validate(schema, data) {
|
|
198
|
+
try {
|
|
199
|
+
const result = schema.parse(data);
|
|
200
|
+
return { success: true, data: result };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error instanceof z.ZodError) {
|
|
203
|
+
const errors = {};
|
|
204
|
+
error.errors.forEach(err => {
|
|
205
|
+
const path = err.path.join('.');
|
|
206
|
+
errors[path] = err.message;
|
|
207
|
+
});
|
|
208
|
+
return { success: false, errors };
|
|
209
|
+
}
|
|
210
|
+
return { success: false, errors: { general: 'Validation failed' } };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Validate a single field
|
|
216
|
+
* @param {z.ZodSchema} schema - Field schema
|
|
217
|
+
* @param {any} value - Field value
|
|
218
|
+
* @returns {Object} { success: boolean, error?: string }
|
|
219
|
+
*/
|
|
220
|
+
static validateField(schema, value) {
|
|
221
|
+
try {
|
|
222
|
+
schema.parse(value);
|
|
223
|
+
return { success: true };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (error instanceof z.ZodError) {
|
|
226
|
+
return { success: false, error: error.errors[0]?.message || 'Invalid value' };
|
|
227
|
+
}
|
|
228
|
+
return { success: false, error: 'Validation failed' };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get validation error message for a field
|
|
234
|
+
* @param {Object} validationResult - Result from validate()
|
|
235
|
+
* @param {string} fieldPath - Dot notation field path
|
|
236
|
+
* @returns {string|null} Error message or null
|
|
237
|
+
*/
|
|
238
|
+
static getFieldError(validationResult, fieldPath) {
|
|
239
|
+
if (validationResult.success) {return null;}
|
|
240
|
+
return validationResult.errors?.[fieldPath] || null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Check if a field has validation errors
|
|
245
|
+
* @param {Object} validationResult - Result from validate()
|
|
246
|
+
* @param {string} fieldPath - Dot notation field path
|
|
247
|
+
* @returns {boolean} True if field has errors
|
|
248
|
+
*/
|
|
249
|
+
static hasFieldError(validationResult, fieldPath) {
|
|
250
|
+
return !!this.getFieldError(validationResult, fieldPath);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Predefined validation rules for common use cases
|
|
256
|
+
*/
|
|
257
|
+
export const rules = {
|
|
258
|
+
required: (message = 'This field is required') =>
|
|
259
|
+
z.string().min(1, message),
|
|
260
|
+
|
|
261
|
+
email: (message = 'Please enter a valid email address') =>
|
|
262
|
+
z.string().email(message),
|
|
263
|
+
|
|
264
|
+
minLength: (min, message) =>
|
|
265
|
+
z.string().min(min, message || `Must be at least ${min} characters`),
|
|
266
|
+
|
|
267
|
+
maxLength: (max, message) =>
|
|
268
|
+
z.string().max(max, message || `Must be less than ${max} characters`),
|
|
269
|
+
|
|
270
|
+
pattern: (regex, message) =>
|
|
271
|
+
z.string().regex(regex, message),
|
|
272
|
+
|
|
273
|
+
numeric: (message = 'Must be a number') =>
|
|
274
|
+
z.string().regex(/^\d+$/, message),
|
|
275
|
+
|
|
276
|
+
phone: (message = 'Please enter a valid phone number') =>
|
|
277
|
+
z.string().regex(/^[\+]?[1-9][\d]{0,15}$/, message),
|
|
278
|
+
|
|
279
|
+
url: (message = 'Please enter a valid URL') =>
|
|
280
|
+
z.string().min(1, 'URL is required').url(message)
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// ─────────────────────────────────────────────────────────────
|
|
284
|
+
// AfriFieldBuilder — Chainable field schema builder
|
|
285
|
+
//
|
|
286
|
+
// Design: Strict by default. Convenient by composition.
|
|
287
|
+
//
|
|
288
|
+
// Usage:
|
|
289
|
+
// afri.url() → valid URL, required
|
|
290
|
+
// afri.url().nullable() → valid URL or null
|
|
291
|
+
// afri.url().nullable().emptyAsNull() → "" → null, then validates
|
|
292
|
+
// afri.url().optional() → valid URL or undefined
|
|
293
|
+
// afri.string().trim() → trim whitespace in normalizeInput
|
|
294
|
+
// ─────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Chainable field builder for AfriCode validation schemas.
|
|
298
|
+
* Wraps Zod with framework-level semantics.
|
|
299
|
+
*
|
|
300
|
+
* IMPORTANT: .emptyAsNull() does NOT alter the Zod schema itself.
|
|
301
|
+
* It marks the field for preprocessing via normalizeInput().
|
|
302
|
+
* Validation remains strict — the preprocessing layer is separate.
|
|
303
|
+
*/
|
|
304
|
+
export class AfriFieldBuilder {
|
|
305
|
+
/**
|
|
306
|
+
* @param {z.ZodSchema} baseSchema - The base Zod schema for this field
|
|
307
|
+
* @param {string} fieldType - The field type identifier (e.g. 'url', 'email')
|
|
308
|
+
*/
|
|
309
|
+
constructor(baseSchema, fieldType = 'string') {
|
|
310
|
+
this._baseSchema = baseSchema;
|
|
311
|
+
this._fieldType = fieldType;
|
|
312
|
+
this._isNullable = false;
|
|
313
|
+
this._isOptional = false;
|
|
314
|
+
this._emptyAsNull = false;
|
|
315
|
+
this._trim = false;
|
|
316
|
+
this._customMessage = null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Allow null as a valid value.
|
|
321
|
+
* @returns {AfriFieldBuilder}
|
|
322
|
+
*/
|
|
323
|
+
nullable() {
|
|
324
|
+
this._isNullable = true;
|
|
325
|
+
return this;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Allow undefined (field can be omitted).
|
|
330
|
+
* @returns {AfriFieldBuilder}
|
|
331
|
+
*/
|
|
332
|
+
optional() {
|
|
333
|
+
this._isOptional = true;
|
|
334
|
+
return this;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Mark this field for empty-string-to-null conversion.
|
|
339
|
+
* REQUIRES .nullable() to have been called first.
|
|
340
|
+
* The conversion happens in normalizeInput(), not in the Zod schema.
|
|
341
|
+
*
|
|
342
|
+
* @returns {AfriFieldBuilder}
|
|
343
|
+
* @throws {Error} If .nullable() was not called first
|
|
344
|
+
*/
|
|
345
|
+
emptyAsNull() {
|
|
346
|
+
if (!this._isNullable) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`[AfriCode] .emptyAsNull() requires .nullable() to be called first. ` +
|
|
349
|
+
`Use: afri.${this._fieldType}().nullable().emptyAsNull()`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
this._emptyAsNull = true;
|
|
353
|
+
return this;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Mark this field for whitespace trimming.
|
|
358
|
+
* The trimming happens in normalizeInput(), not in the Zod schema.
|
|
359
|
+
* " hello " → "hello"
|
|
360
|
+
*
|
|
361
|
+
* @returns {AfriFieldBuilder}
|
|
362
|
+
*/
|
|
363
|
+
trim() {
|
|
364
|
+
this._trim = true;
|
|
365
|
+
return this;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Set a custom error message for the base validation.
|
|
370
|
+
* @param {string} message
|
|
371
|
+
* @returns {AfriFieldBuilder}
|
|
372
|
+
*/
|
|
373
|
+
message(message) {
|
|
374
|
+
this._customMessage = message;
|
|
375
|
+
return this;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Build the final Zod schema.
|
|
380
|
+
* @returns {z.ZodSchema}
|
|
381
|
+
*/
|
|
382
|
+
build() {
|
|
383
|
+
let schema = this._baseSchema;
|
|
384
|
+
|
|
385
|
+
if (this._isNullable) {
|
|
386
|
+
schema = schema.nullable();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (this._isOptional) {
|
|
390
|
+
schema = schema.optional();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return schema;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Get the metadata for this field (used by normalizeInput).
|
|
398
|
+
* @returns {Object}
|
|
399
|
+
*/
|
|
400
|
+
getMeta() {
|
|
401
|
+
return {
|
|
402
|
+
fieldType: this._fieldType,
|
|
403
|
+
isNullable: this._isNullable,
|
|
404
|
+
isOptional: this._isOptional,
|
|
405
|
+
emptyAsNull: this._emptyAsNull,
|
|
406
|
+
trim: this._trim
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* AfriCode field factory namespace.
|
|
413
|
+
* Creates AfriFieldBuilder instances for common field types.
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* // Strict URL — required, only valid URLs accepted
|
|
417
|
+
* const urlField = afri.url().build();
|
|
418
|
+
*
|
|
419
|
+
* @example
|
|
420
|
+
* // Nullable URL — valid URL or null
|
|
421
|
+
* const avatarField = afri.url().nullable().build();
|
|
422
|
+
*
|
|
423
|
+
* @example
|
|
424
|
+
* // Nullable URL with empty-string normalization
|
|
425
|
+
* const avatarField = afri.url().nullable().emptyAsNull().build();
|
|
426
|
+
*/
|
|
427
|
+
export const afri = {
|
|
428
|
+
/**
|
|
429
|
+
* Create a strict URL field builder.
|
|
430
|
+
* Empty strings are always rejected at the core.
|
|
431
|
+
* @param {string} [message='Please enter a valid URL']
|
|
432
|
+
* @returns {AfriFieldBuilder}
|
|
433
|
+
*/
|
|
434
|
+
url(message = 'Please enter a valid URL') {
|
|
435
|
+
return new AfriFieldBuilder(
|
|
436
|
+
z.string().min(1, 'URL cannot be empty').url(message),
|
|
437
|
+
'url'
|
|
438
|
+
);
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Create a strict email field builder.
|
|
443
|
+
* @param {string} [message='Please enter a valid email address']
|
|
444
|
+
* @returns {AfriFieldBuilder}
|
|
445
|
+
*/
|
|
446
|
+
email(message = 'Please enter a valid email address') {
|
|
447
|
+
return new AfriFieldBuilder(
|
|
448
|
+
z.string().min(1, 'Email cannot be empty').email(message),
|
|
449
|
+
'email'
|
|
450
|
+
);
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Create a string field builder.
|
|
455
|
+
* @param {Object} [opts]
|
|
456
|
+
* @param {number} [opts.min] - Minimum length
|
|
457
|
+
* @param {number} [opts.max] - Maximum length
|
|
458
|
+
* @param {string} [opts.message] - Custom error message
|
|
459
|
+
* @returns {AfriFieldBuilder}
|
|
460
|
+
*/
|
|
461
|
+
string(opts = {}) {
|
|
462
|
+
let schema = z.string();
|
|
463
|
+
if (opts.min !== undefined) {schema = schema.min(opts.min, opts.message || `Must be at least ${opts.min} characters`);}
|
|
464
|
+
if (opts.max !== undefined) {schema = schema.max(opts.max, opts.message || `Must be less than ${opts.max} characters`);}
|
|
465
|
+
return new AfriFieldBuilder(schema, 'string');
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Create a number field builder.
|
|
470
|
+
* @param {Object} [opts]
|
|
471
|
+
* @param {number} [opts.min] - Minimum value
|
|
472
|
+
* @param {number} [opts.max] - Maximum value
|
|
473
|
+
* @param {boolean} [opts.int] - Must be integer
|
|
474
|
+
* @returns {AfriFieldBuilder}
|
|
475
|
+
*/
|
|
476
|
+
number(opts = {}) {
|
|
477
|
+
let schema = z.number();
|
|
478
|
+
if (opts.min !== undefined) {schema = schema.min(opts.min);}
|
|
479
|
+
if (opts.max !== undefined) {schema = schema.max(opts.max);}
|
|
480
|
+
if (opts.int) {schema = schema.int('Must be a whole number');}
|
|
481
|
+
return new AfriFieldBuilder(schema, 'number');
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Create a phone field builder.
|
|
486
|
+
* @param {string} [message='Please enter a valid phone number']
|
|
487
|
+
* @returns {AfriFieldBuilder}
|
|
488
|
+
*/
|
|
489
|
+
phone(message = 'Please enter a valid phone number') {
|
|
490
|
+
return new AfriFieldBuilder(
|
|
491
|
+
z.string().regex(/^[\+]?[1-9][\d]{0,15}$/, message),
|
|
492
|
+
'phone'
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Normalize input data before validation.
|
|
499
|
+
*
|
|
500
|
+
* This is the preprocessing layer. It examines field metadata from
|
|
501
|
+
* AfriFieldBuilder instances and applies transformations:
|
|
502
|
+
*
|
|
503
|
+
* 1. Trim whitespace (if .trim() or config.validation.trimStrings)
|
|
504
|
+
* 2. Convert "" → null (if .emptyAsNull())
|
|
505
|
+
*
|
|
506
|
+
* Order matters: trim runs BEFORE emptyAsNull so that
|
|
507
|
+
* " " → "" → null works correctly.
|
|
508
|
+
*
|
|
509
|
+
* Separation of concerns:
|
|
510
|
+
* - normalizeInput() = input adapter (flexible)
|
|
511
|
+
* - Validation.validate() = core validation (strict)
|
|
512
|
+
*
|
|
513
|
+
* @param {Object} fieldBuilders - Map of field names to AfriFieldBuilder instances
|
|
514
|
+
* @param {Object} data - Raw input data (e.g. form submission)
|
|
515
|
+
* @returns {Object} Normalized data ready for validation
|
|
516
|
+
*
|
|
517
|
+
* @example
|
|
518
|
+
* const fields = {
|
|
519
|
+
* avatar_url: afri.url().nullable().emptyAsNull(),
|
|
520
|
+
* name: afri.string({ min: 2 }).trim(),
|
|
521
|
+
* bio: afri.string({ max: 500 }).nullable()
|
|
522
|
+
* };
|
|
523
|
+
*
|
|
524
|
+
* const raw = { avatar_url: '', name: ' AfriCode ', bio: 'Hello' };
|
|
525
|
+
* const cleaned = normalizeInput(fields, raw);
|
|
526
|
+
* // → { avatar_url: null, name: 'AfriCode', bio: 'Hello' }
|
|
527
|
+
*/
|
|
528
|
+
export function normalizeInput(fieldBuilders, data) {
|
|
529
|
+
const result = { ...data };
|
|
530
|
+
let globalTrim = false;
|
|
531
|
+
|
|
532
|
+
// Check framework config for global trim setting
|
|
533
|
+
try {
|
|
534
|
+
const config = getConfig();
|
|
535
|
+
globalTrim = config.validation?.trimStrings === true;
|
|
536
|
+
} catch {
|
|
537
|
+
// Config not initialized yet — use defaults (no global trim)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
for (const [fieldName, builder] of Object.entries(fieldBuilders)) {
|
|
541
|
+
if (!(builder instanceof AfriFieldBuilder)) {continue;}
|
|
542
|
+
|
|
543
|
+
const meta = builder.getMeta();
|
|
544
|
+
const value = result[fieldName];
|
|
545
|
+
|
|
546
|
+
// Step 1: Trim whitespace (per-field .trim() or global config)
|
|
547
|
+
if (typeof value === 'string' && (meta.trim || globalTrim)) {
|
|
548
|
+
result[fieldName] = value.trim();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Step 2: Convert empty string to null (per-field .emptyAsNull())
|
|
552
|
+
if (meta.emptyAsNull && result[fieldName] === '') {
|
|
553
|
+
result[fieldName] = null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Build a Zod object schema from a map of AfriFieldBuilder instances.
|
|
562
|
+
* Convenience method to go from builders → Zod schema in one step.
|
|
563
|
+
*
|
|
564
|
+
* @param {Object} fieldBuilders - Map of field names to AfriFieldBuilder instances
|
|
565
|
+
* @returns {z.ZodObject}
|
|
566
|
+
*
|
|
567
|
+
* @example
|
|
568
|
+
* const schema = buildSchema({
|
|
569
|
+
* avatar_url: afri.url().nullable().emptyAsNull(),
|
|
570
|
+
* name: afri.string({ min: 2, max: 50 })
|
|
571
|
+
* });
|
|
572
|
+
*
|
|
573
|
+
* const result = Validation.validate(schema, data);
|
|
574
|
+
*/
|
|
575
|
+
export function buildSchema(fieldBuilders) {
|
|
576
|
+
const shape = {};
|
|
577
|
+
|
|
578
|
+
for (const [fieldName, builder] of Object.entries(fieldBuilders)) {
|
|
579
|
+
if (builder instanceof AfriFieldBuilder) {
|
|
580
|
+
shape[fieldName] = builder.build();
|
|
581
|
+
} else {
|
|
582
|
+
// Allow raw Zod schemas to be mixed in
|
|
583
|
+
shape[fieldName] = builder;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return z.object(shape);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export default { schemas, Validation, rules, afri, AfriFieldBuilder, normalizeInput, buildSchema };
|