@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.
Files changed (136) hide show
  1. package/AFRICODE_FRAMEWORK_GUIDE.md +707 -0
  2. package/LICENSE +623 -0
  3. package/README.md +442 -0
  4. package/bin/africode.js +73 -0
  5. package/bin/africode.js.1758507140 +343 -0
  6. package/bin/cli.ts +83 -0
  7. package/bin/create-africode.js +158 -0
  8. package/bin/scaffold.ts +219 -0
  9. package/components/accordion.js +183 -0
  10. package/components/alert.js +131 -0
  11. package/components/auth.js +172 -0
  12. package/components/avatar.js +117 -0
  13. package/components/badge.js +104 -0
  14. package/components/base.d.ts +139 -0
  15. package/components/base.js +184 -0
  16. package/components/button.js +164 -0
  17. package/components/card.js +137 -0
  18. package/components/cultural-card.js +243 -0
  19. package/components/divider.js +83 -0
  20. package/components/dropdown.js +171 -0
  21. package/components/error-boundary.js +155 -0
  22. package/components/form.js +131 -0
  23. package/components/grid.js +273 -0
  24. package/components/hero.js +138 -0
  25. package/components/icon.js +36 -0
  26. package/components/index.js +57 -0
  27. package/components/input.js +256 -0
  28. package/components/kanga-card.js +185 -0
  29. package/components/language-switcher.js +108 -0
  30. package/components/loader.js +80 -0
  31. package/components/modal.js +262 -0
  32. package/components/motion.js +84 -0
  33. package/components/navbar.js +236 -0
  34. package/components/pattern-showcase.js +225 -0
  35. package/components/progress.js +134 -0
  36. package/components/react.js +111 -0
  37. package/components/section.js +54 -0
  38. package/components/select.js +322 -0
  39. package/components/sidebar.js +180 -0
  40. package/components/skeleton.js +85 -0
  41. package/components/table.js +181 -0
  42. package/components/tabs.js +202 -0
  43. package/components/theme-toggle.js +82 -0
  44. package/components/toast.js +139 -0
  45. package/components/tooltip.js +167 -0
  46. package/core/a2ui-schema-manager.js +344 -0
  47. package/core/a2ui.js +431 -0
  48. package/core/bun-runtime.js +799 -0
  49. package/core/cli/commands/add.js +23 -0
  50. package/core/cli/commands/audit.js +58 -0
  51. package/core/cli/commands/build.js +137 -0
  52. package/core/cli/commands/create-plugin.js +241 -0
  53. package/core/cli/commands/dev.js +228 -0
  54. package/core/cli/commands/lint.js +23 -0
  55. package/core/cli/commands/test.js +34 -0
  56. package/core/cli/migrator.js +71 -0
  57. package/core/cli/ui.js +46 -0
  58. package/core/compliance.js +628 -0
  59. package/core/config.js +263 -0
  60. package/core/db-advanced.js +481 -0
  61. package/core/db.js +284 -0
  62. package/core/enhanced-hmr.js +404 -0
  63. package/core/errors.js +222 -0
  64. package/core/file-router.js +290 -0
  65. package/core/heartbeat.js +64 -0
  66. package/core/hmr-client.js +204 -0
  67. package/core/hmr.js +196 -0
  68. package/core/html.d.ts +116 -0
  69. package/core/html.js +160 -0
  70. package/core/hydration.js +52 -0
  71. package/core/lipa-namba-journey.js +572 -0
  72. package/core/motion.js +106 -0
  73. package/core/nida-cig-middleware.js +455 -0
  74. package/core/patterns.d.ts +124 -0
  75. package/core/patterns.js +833 -0
  76. package/core/plugins/index.js +312 -0
  77. package/core/router.js +387 -0
  78. package/core/sdk-client.js +62 -0
  79. package/core/sdk.d.ts +133 -0
  80. package/core/sdk.js +123 -0
  81. package/core/seo.js +76 -0
  82. package/core/server/auth-endpoints.js +339 -0
  83. package/core/server/auth.js +180 -0
  84. package/core/server/csrf.js +206 -0
  85. package/core/server/db.js +39 -0
  86. package/core/server/middleware.js +324 -0
  87. package/core/server/rate-limit.js +238 -0
  88. package/core/server/render.js +69 -0
  89. package/core/server/router.js +120 -0
  90. package/core/shim.js +28 -0
  91. package/core/state.d.ts +86 -0
  92. package/core/state.js +242 -0
  93. package/core/store.d.ts +122 -0
  94. package/core/store.js +61 -0
  95. package/core/validation.d.ts +233 -0
  96. package/core/validation.js +590 -0
  97. package/core/websocket.js +639 -0
  98. package/dist/africode.js +2905 -0
  99. package/dist/africode.js.map +61 -0
  100. package/dist/build-info.json +23 -0
  101. package/dist/components.js +2888 -0
  102. package/dist/components.js.map +58 -0
  103. package/dist/styles/africanity.css +322 -0
  104. package/dist/styles/typography.css +141 -0
  105. package/docs/IDE-Guide.md +50 -0
  106. package/package.json +110 -0
  107. package/src/index.ts +196 -0
  108. package/styles/africanity.css +322 -0
  109. package/styles/typography.css +141 -0
  110. package/templates/starter/.env.example +15 -0
  111. package/templates/starter/africode.config.js +40 -0
  112. package/templates/starter/package.json +14 -0
  113. package/templates/starter/src/pages/index.html +46 -0
  114. package/templates/starter/src/pages/index.js +32 -0
  115. package/templates/starter/src/styles/main.css +4 -0
  116. package/templates/starter-3d/.env.example +7 -0
  117. package/templates/starter-3d/africode.config.js +29 -0
  118. package/templates/starter-3d/components/af-model-viewer.js +125 -0
  119. package/templates/starter-3d/package.json +15 -0
  120. package/templates/starter-3d/src/pages/index.html +46 -0
  121. package/templates/starter-3d/src/pages/index.js +50 -0
  122. package/templates/starter-3d/src/styles/main.css +4 -0
  123. package/templates/starter-react/.env.example +15 -0
  124. package/templates/starter-react/africode.config.js +40 -0
  125. package/templates/starter-react/package.json +16 -0
  126. package/templates/starter-react/src/pages/index.html +46 -0
  127. package/templates/starter-react/src/pages/index.js +68 -0
  128. package/templates/starter-react/src/styles/main.css +4 -0
  129. package/templates/starter-tailwind/.env.example +15 -0
  130. package/templates/starter-tailwind/africode.config.js +40 -0
  131. package/templates/starter-tailwind/package.json +20 -0
  132. package/templates/starter-tailwind/src/pages/index.html +46 -0
  133. package/templates/starter-tailwind/src/pages/index.js +37 -0
  134. package/templates/starter-tailwind/src/styles/main.css +4 -0
  135. package/templates/starter-tailwind/src/styles/tailwind.css +1 -0
  136. 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 };