bunigniter 0.2.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/dist/LICENSE +21 -0
  4. package/dist/README.md +229 -0
  5. package/dist/base/controller.ts +324 -0
  6. package/dist/base/index.ts +5 -0
  7. package/dist/base/service.ts +21 -0
  8. package/dist/cli/index.ts +318 -0
  9. package/dist/cli/list-routes.ts +72 -0
  10. package/dist/cli/repl.ts +461 -0
  11. package/dist/cli/templates.ts +283 -0
  12. package/dist/client/index.ts +159 -0
  13. package/dist/db/drizzle.ts +550 -0
  14. package/dist/db/validators.ts +229 -0
  15. package/dist/edge-builder.ts +120 -0
  16. package/dist/edge.ts +69 -0
  17. package/dist/helpers/cache.ts +173 -0
  18. package/dist/helpers/cors.ts +103 -0
  19. package/dist/helpers/csrf.ts +155 -0
  20. package/dist/helpers/debug.ts +158 -0
  21. package/dist/helpers/env.ts +147 -0
  22. package/dist/helpers/handler.ts +158 -0
  23. package/dist/helpers/http.ts +194 -0
  24. package/dist/helpers/image.ts +217 -0
  25. package/dist/helpers/jwt.ts +147 -0
  26. package/dist/helpers/logger.ts +96 -0
  27. package/dist/helpers/mail.ts +272 -0
  28. package/dist/helpers/middleware-loader.ts +116 -0
  29. package/dist/helpers/middleware.ts +57 -0
  30. package/dist/helpers/modules.ts +115 -0
  31. package/dist/helpers/openapi.ts +140 -0
  32. package/dist/helpers/pagination.ts +159 -0
  33. package/dist/helpers/queue.ts +186 -0
  34. package/dist/helpers/request-context.ts +13 -0
  35. package/dist/helpers/request.ts +376 -0
  36. package/dist/helpers/schedule.ts +173 -0
  37. package/dist/helpers/session-middleware.ts +89 -0
  38. package/dist/helpers/session.ts +286 -0
  39. package/dist/helpers/sse.ts +90 -0
  40. package/dist/helpers/throttle.ts +156 -0
  41. package/dist/helpers/upload.ts +417 -0
  42. package/dist/helpers/validator.ts +287 -0
  43. package/dist/helpers/ws.ts +123 -0
  44. package/dist/index.ts +221 -0
  45. package/dist/package.json +70 -0
  46. package/dist/router/file-router.ts +541 -0
  47. package/dist/router/server-router.ts +103 -0
  48. package/dist/view/page.ts +96 -0
  49. package/dist/view/renderer.tsx +390 -0
  50. package/dist/view/view-response.ts +10 -0
  51. package/package.json +70 -0
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Upload — file upload handling with validation.
3
+ *
4
+ * Usage in a Controller:
5
+ * ```ts
6
+ * // Auto-detect body from context (recommended)
7
+ * const file = await this.upload.file('avatar')
8
+ *
9
+ * // Or explicitly pass body
10
+ * const file = this.upload.file(this.body, 'avatar')
11
+ *
12
+ * if (!file) return this.badRequest({ avatar: 'File is required' })
13
+ * if (file.fails()) return this.badRequest({ avatar: file.errors() })
14
+ *
15
+ * const path = await this.upload.store(file, 'avatars')
16
+ * return this.json({ path })
17
+ * ```
18
+ */
19
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
20
+ import { join, extname } from "node:path";
21
+ import { env } from "./env";
22
+ import crypto from "node:crypto";
23
+
24
+ export interface UploadOptions {
25
+ /** Maximum file size in bytes. Default: 10MB */
26
+ maxSize?: number;
27
+
28
+ /** Allowed MIME types. Default: all */
29
+ allowedMimes?: string[];
30
+
31
+ /** Allowed file extensions (including dot, e.g. '.jpg'). Default: all */
32
+ allowedExts?: string[];
33
+
34
+ /** Storage directory relative to CWD. Default: 'storage' */
35
+ storageDir?: string;
36
+
37
+ /** Max files per request. Default: 10 */
38
+ maxFiles?: number;
39
+
40
+ /** Whether to overwrite existing files. Default: false */
41
+ overwrite?: boolean;
42
+ }
43
+
44
+ export interface UploadedFile {
45
+ /** Original form field name. */
46
+ field: string;
47
+
48
+ /** Original file name from the client. */
49
+ name: string;
50
+
51
+ /** File size in bytes. */
52
+ size: number;
53
+
54
+ /** MIME type. */
55
+ type: string;
56
+
57
+ /** Stored path relative to storage dir (set after store()). */
58
+ storedPath?: string;
59
+
60
+ /** Absolute path on disk (set after store()). */
61
+ absolutePath?: string;
62
+
63
+ /** Validation errors. */
64
+ _errors?: string[];
65
+
66
+ /** Internal: reference to the raw File/Blob object. */
67
+ _raw?: any;
68
+ }
69
+
70
+ /**
71
+ * Upload service — handle file uploads with validation and storage.
72
+ */
73
+ export class Upload {
74
+ private options: Required<UploadOptions>;
75
+ private _body: any;
76
+
77
+ constructor(options: UploadOptions = {}) {
78
+ this.options = {
79
+ maxSize: options.maxSize ?? 10 * 1024 * 1024,
80
+ allowedMimes: options.allowedMimes ?? [],
81
+ allowedExts: options.allowedExts ?? [],
82
+ storageDir: options.storageDir ?? env("STORAGE_DIR", "storage"),
83
+ maxFiles: options.maxFiles ?? 10,
84
+ overwrite: options.overwrite ?? false,
85
+ };
86
+ this._body = null;
87
+ }
88
+
89
+ /**
90
+ * Set the request body for auto-detection.
91
+ * Called internally by the framework.
92
+ */
93
+ set body(body: any) {
94
+ this._body = body;
95
+ }
96
+
97
+ // ─── File Detection ─────────────────────────────────────
98
+
99
+ /**
100
+ * Check if a file was uploaded for the given field.
101
+ *
102
+ * @param body - Request body (or omit to auto-detect from controller context)
103
+ * @param field - Form field name
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * if (this.upload.hasFile('avatar')) { ... }
108
+ * if (this.upload.hasFile(this.body, 'avatar')) { ... }
109
+ * ```
110
+ */
111
+ hasFile(bodyOrField: any, field?: string): boolean {
112
+ if (field === undefined) {
113
+ field = bodyOrField as string;
114
+ bodyOrField = this._body;
115
+ }
116
+ if (!bodyOrField || typeof bodyOrField !== "object") return false;
117
+ const raw = (bodyOrField as Record<string, any>)[field!];
118
+ if (!raw) return false;
119
+
120
+ if (Array.isArray(raw)) return raw.length > 0 && this._isFile(raw[0]);
121
+ return this._isFile(raw);
122
+ }
123
+
124
+ // ─── File Retrieval ─────────────────────────────────────
125
+
126
+ /**
127
+ * Get a single uploaded file from the request.
128
+ *
129
+ * @param body - Request body, or field name (auto-detect from context)
130
+ * @param field - Form field name (omit if first arg is field name)
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * // Auto-detect body from controller context
135
+ * const file = await this.upload.file('avatar')
136
+ *
137
+ * // Explicit body
138
+ * const file = this.upload.file(this.body, 'avatar')
139
+ * ```
140
+ */
141
+ async file(bodyOrField: any, field?: string): Promise<UploadedFile | null> {
142
+ if (field === undefined) {
143
+ field = bodyOrField as string;
144
+ bodyOrField = this._body;
145
+ }
146
+ if (!bodyOrField || typeof bodyOrField !== "object") return null;
147
+
148
+ const raw = (bodyOrField as Record<string, any>)[field!];
149
+ if (!raw) return null;
150
+
151
+ // Handle single file
152
+ if (!Array.isArray(raw)) {
153
+ return this._parseFile(raw, field!);
154
+ }
155
+
156
+ // Array but only one item
157
+ if (raw.length === 1) {
158
+ return this._parseFile(raw[0], field!);
159
+ }
160
+
161
+ // Multiple files — return first, log warning
162
+ if (raw.length > 1) {
163
+ console.warn(
164
+ `[upload] Multiple files for field "${field}", use .files() instead`,
165
+ );
166
+ return this._parseFile(raw[0], field!);
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * Get multiple uploaded files from the request.
174
+ *
175
+ * @param body - Request body, or field name (auto-detect from context)
176
+ * @param field - Form field name (omit if first arg is field name)
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * const files = await this.upload.files('gallery')
181
+ * for (const file of files) {
182
+ * await this.upload.store(file, 'gallery')
183
+ * }
184
+ * ```
185
+ */
186
+ async files(bodyOrField: any, field?: string): Promise<UploadedFile[]> {
187
+ if (field === undefined) {
188
+ field = bodyOrField as string;
189
+ bodyOrField = this._body;
190
+ }
191
+ if (!bodyOrField || typeof bodyOrField !== "object") return [];
192
+
193
+ const raw = (bodyOrField as Record<string, any>)[field!];
194
+ if (!raw) return [];
195
+
196
+ const items = Array.isArray(raw) ? raw : [raw];
197
+ const result: UploadedFile[] = [];
198
+
199
+ for (const item of items) {
200
+ if (this._isFile(item)) {
201
+ result.push(await this._parseFile(item, field!));
202
+ }
203
+ }
204
+
205
+ return result;
206
+ }
207
+
208
+ // ─── Validation ─────────────────────────────────────────
209
+
210
+ /**
211
+ * Validate a file against configured rules.
212
+ * Returns error messages array, or empty array if valid.
213
+ */
214
+ validate(file: UploadedFile): string[] {
215
+ const errors: string[] = [];
216
+
217
+ // Size check
218
+ if (file.size > this.options.maxSize) {
219
+ const mb = (this.options.maxSize / (1024 * 1024)).toFixed(1);
220
+ errors.push(`File exceeds maximum size of ${mb}MB`);
221
+ }
222
+
223
+ // MIME check
224
+ if (
225
+ this.options.allowedMimes.length > 0 &&
226
+ !this.options.allowedMimes.includes(file.type)
227
+ ) {
228
+ errors.push(`File type "${file.type}" is not allowed`);
229
+ }
230
+
231
+ // Extension check
232
+ if (this.options.allowedExts.length > 0) {
233
+ const ext = this.extension(file);
234
+ if (!this.options.allowedExts.includes(ext)) {
235
+ errors.push(
236
+ `File extension "${ext}" is not allowed (allowed: ${this.options.allowedExts.join(", ")})`,
237
+ );
238
+ }
239
+ }
240
+
241
+ return errors;
242
+ }
243
+
244
+ /** Get file extension from the original name. */
245
+ extension(file: UploadedFile): string {
246
+ return extname(file.name).toLowerCase();
247
+ }
248
+
249
+ // ─── Storage ────────────────────────────────────────────
250
+
251
+ /**
252
+ * Store a file to disk.
253
+ *
254
+ * @param file - Uploaded file
255
+ * @param subdir - Subdirectory within storage dir (e.g. 'avatars')
256
+ * @param filename - Custom filename (without extension). Default: timestamp_random
257
+ * @returns The stored file path relative to storage dir
258
+ *
259
+ * @example
260
+ * ```ts
261
+ * const path = await this.upload.store(file, 'avatars')
262
+ * const path = await this.upload.store(file, 'avatars', 'profile')
263
+ * // → 'avatars/1748200000_a1b2c3d4.jpg'
264
+ * // → 'avatars/profile.jpg'
265
+ * ```
266
+ */
267
+ async store(
268
+ file: UploadedFile,
269
+ subdir = "",
270
+ filename?: string,
271
+ ): Promise<string> {
272
+ const storageDir = join(process.cwd(), this.options.storageDir, subdir);
273
+ if (!existsSync(storageDir)) {
274
+ mkdirSync(storageDir, { recursive: true });
275
+ }
276
+
277
+ const ext = this.extension(file);
278
+ const name = filename
279
+ ? `${filename}${ext}`
280
+ : `${Date.now()}_${crypto.randomUUID().slice(0, 8)}${ext}`;
281
+
282
+ const fullPath = join(storageDir, name);
283
+
284
+ // Check existing
285
+ if (existsSync(fullPath) && !this.options.overwrite) {
286
+ throw new Error(`File already exists: ${name}`);
287
+ }
288
+
289
+ // Write file from various sources
290
+ const buffer = await this._getBuffer(file);
291
+ writeFileSync(fullPath, buffer);
292
+
293
+ // Update file metadata
294
+ file.storedPath = subdir ? `${subdir}/${name}` : name;
295
+ file.absolutePath = fullPath;
296
+
297
+ return file.storedPath;
298
+ }
299
+
300
+ /**
301
+ * Delete a stored file from disk.
302
+ *
303
+ * @param path - Relative path from storage dir (e.g. 'avatars/abc.jpg')
304
+ * @returns true if deleted, false if not found
305
+ *
306
+ * @example
307
+ * ```ts
308
+ * await this.upload.delete('avatars/old.jpg')
309
+ * ```
310
+ */
311
+ delete(subpath: string): boolean {
312
+ const fullPath = join(process.cwd(), this.options.storageDir, subpath);
313
+ if (!existsSync(fullPath)) return false;
314
+ unlinkSync(fullPath);
315
+ return true;
316
+ }
317
+
318
+ /**
319
+ * Delete the stored file associated with an UploadedFile.
320
+ */
321
+ deleteFile(file: UploadedFile): boolean {
322
+ if (!file.absolutePath) return false;
323
+ if (!existsSync(file.absolutePath)) return false;
324
+ unlinkSync(file.absolutePath);
325
+ return true;
326
+ }
327
+
328
+ /** Get the configured max upload size in bytes. */
329
+ get maxSize(): number {
330
+ return this.options.maxSize;
331
+ }
332
+
333
+ /** Get the configured storage directory. */
334
+ get storageDir(): string {
335
+ return this.options.storageDir;
336
+ }
337
+
338
+ // ─── Internal Helpers ───────────────────────────────────
339
+
340
+ /** Check if a raw value looks like a file. */
341
+ private _isFile(raw: any): boolean {
342
+ if (!raw) return false;
343
+ return (
344
+ raw instanceof File ||
345
+ raw instanceof Blob ||
346
+ typeof raw?.name === "string"
347
+ );
348
+ }
349
+
350
+ /** Parse a raw file value into an UploadedFile. */
351
+ private async _parseFile(raw: any, field: string): Promise<UploadedFile> {
352
+ const file: UploadedFile = {
353
+ field,
354
+ name: raw.name ?? "unknown",
355
+ size: raw.size ?? 0,
356
+ type: raw.type ?? "application/octet-stream",
357
+ _raw: raw,
358
+ };
359
+
360
+ // Validate
361
+ const errors = this.validate(file);
362
+ if (errors.length > 0) {
363
+ file._errors = errors;
364
+ }
365
+
366
+ return file;
367
+ }
368
+
369
+ /** Extract file buffer from various sources. */
370
+ private async _getBuffer(file: UploadedFile): Promise<Buffer> {
371
+ // Use stored raw reference first
372
+ if (file._raw) {
373
+ if (typeof file._raw.arrayBuffer === "function") {
374
+ const ab = await file._raw.arrayBuffer();
375
+ return Buffer.from(ab);
376
+ }
377
+ if (file._raw instanceof Blob || file._raw instanceof File) {
378
+ const ab = await file._raw.arrayBuffer();
379
+ return Buffer.from(ab);
380
+ }
381
+ }
382
+
383
+ // Fallback: search body for matching File object
384
+ if (this._body) {
385
+ for (const key of Object.keys(this._body)) {
386
+ const raw = this._body[key];
387
+ const items = Array.isArray(raw) ? raw : [raw];
388
+ for (const item of items) {
389
+ if (typeof item?.arrayBuffer === "function") {
390
+ try {
391
+ const ab = await item.arrayBuffer();
392
+ return Buffer.from(ab);
393
+ } catch {
394
+ // continue searching
395
+ }
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ throw new Error(
402
+ `Cannot read file buffer for "${file.name}". Ensure multipart form data is properly parsed.`,
403
+ );
404
+ }
405
+ }
406
+
407
+ let _uploadInstance: Upload | null = null;
408
+
409
+ /**
410
+ * Create or retrieve the global Upload instance.
411
+ */
412
+ export function createUpload(options?: UploadOptions): Upload {
413
+ if (!_uploadInstance) {
414
+ _uploadInstance = new Upload(options);
415
+ }
416
+ return _uploadInstance;
417
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Validation helper — CodeIgniter-style string rules + Zod integration.
3
+ *
4
+ * Two modes:
5
+ * 1. String rules: `this.validate(body, { name: 'required|min:2' })`
6
+ * 2. Zod schema: `this.validate(body, z.object({ name: z.string().min(2) }))`
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * // String rules (CodeIgniter style)
11
+ * const v = this.validate(this.body, {
12
+ * name: 'required|min:2|max:100',
13
+ * email: 'required|email'
14
+ * })
15
+ * if (v.fails()) return this.badRequest(v.errors())
16
+ *
17
+ * // Zod schema (TypeScript style)
18
+ * const schema = z.object({ name: z.string().min(2), email: z.string().email() })
19
+ * const v = this.validate(this.body, schema)
20
+ * if (v.fails()) return this.badRequest(v.errors())
21
+ * ```
22
+ */
23
+ import type { z } from 'zod'
24
+
25
+ // ─── Types ─────────────────────────────────────────────────────
26
+
27
+ /** Validation errors map: field → messages[] */
28
+ export interface ValidationErrors {
29
+ [field: string]: string[]
30
+ }
31
+
32
+ /** Validation result. */
33
+ export class ValidationResult<T = Record<string, any>> {
34
+ constructor(
35
+ public passes: boolean,
36
+ public data: T,
37
+ public errors: ValidationErrors = {}
38
+ ) {}
39
+
40
+ fails(): boolean {
41
+ return !this.passes
42
+ }
43
+
44
+ /** Get first error for a field. */
45
+ first(field: string): string | null {
46
+ return this.errors[field]?.[0] ?? null
47
+ }
48
+
49
+ /** Get all errors for a field. */
50
+ get(field: string): string[] {
51
+ return this.errors[field] ?? []
52
+ }
53
+
54
+ /** Convert to plain object (for JSON response). */
55
+ toJSON(): { passes: boolean; errors: ValidationErrors } {
56
+ return { passes: this.passes, errors: this.errors }
57
+ }
58
+ }
59
+
60
+ // ─── String Rule Validator ─────────────────────────────────────
61
+
62
+ type Rules = Record<string, string>
63
+
64
+ /** Built-in validation rules. */
65
+ const RULES: Record<string, (value: any, param?: string) => string | null> = {
66
+ required: (value) =>
67
+ value === undefined || value === null || value === '' ? 'This field is required' : null,
68
+
69
+ min: (value, param) => {
70
+ const min = Number(param)
71
+ if (isNaN(min)) return null
72
+ if (typeof value === 'string' && value.length < min) return `Must be at least ${min} characters`
73
+ if (typeof value === 'number' && value < min) return `Must be at least ${min}`
74
+ return null
75
+ },
76
+
77
+ max: (value, param) => {
78
+ const max = Number(param)
79
+ if (isNaN(max)) return null
80
+ if (typeof value === 'string' && value.length > max) return `Must not exceed ${max} characters`
81
+ if (typeof value === 'number' && value > max) return `Must not exceed ${max}`
82
+ return null
83
+ },
84
+
85
+ email: (value) =>
86
+ typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
87
+ ? 'Must be a valid email address'
88
+ : null,
89
+
90
+ numeric: (value) =>
91
+ typeof value !== 'number' && isNaN(Number(value)) ? 'Must be a number' : null,
92
+
93
+ integer: (value) =>
94
+ !Number.isInteger(Number(value)) ? 'Must be an integer' : null,
95
+
96
+ boolean: (value) =>
97
+ typeof value !== 'boolean' && !['true', 'false', '0', '1'].includes(String(value))
98
+ ? 'Must be a boolean'
99
+ : null,
100
+
101
+ url: (value) =>
102
+ typeof value === 'string' && !/^https?:\/\/.+/.test(value) ? 'Must be a valid URL' : null,
103
+
104
+ alpha: (value) =>
105
+ typeof value === 'string' && !/^[a-zA-Z]+$/.test(value) ? 'Must contain only letters' : null,
106
+
107
+ alpha_num: (value) =>
108
+ typeof value === 'string' && !/^[a-zA-Z0-9]+$/.test(value)
109
+ ? 'Must contain only letters and numbers'
110
+ : null,
111
+
112
+ alpha_dash: (value) =>
113
+ typeof value === 'string' && !/^[a-zA-Z0-9_-]+$/.test(value)
114
+ ? 'Must contain only letters, numbers, dashes, and underscores'
115
+ : null,
116
+
117
+ same: (value, param, data) => {
118
+ if (!param) return null
119
+ return data?.[param] !== value ? `Must match ${param}` : null
120
+ },
121
+
122
+ differs: (value, param, data) => {
123
+ if (!param) return null
124
+ return data?.[param] === value ? `Must differ from ${param}` : null
125
+ },
126
+
127
+ regex: (value, param) => {
128
+ if (!param) return null
129
+ try {
130
+ return new RegExp(param).test(String(value)) ? null : 'Format is invalid'
131
+ } catch {
132
+ return null
133
+ }
134
+ },
135
+
136
+ date: (value) =>
137
+ typeof value === 'string' && isNaN(Date.parse(value)) ? 'Must be a valid date' : null,
138
+
139
+ after: (value, param) => {
140
+ if (!param || typeof value !== 'string') return null
141
+ return Date.parse(value) <= Date.parse(param) ? `Must be after ${param}` : null
142
+ },
143
+
144
+ before: (value, param) => {
145
+ if (!param || typeof value !== 'string') return null
146
+ return Date.parse(value) >= Date.parse(param) ? `Must be before ${param}` : null
147
+ },
148
+
149
+ 'size': (value, param) => {
150
+ const size = Number(param)
151
+ if (isNaN(size)) return null
152
+ if (typeof value === 'string' && value.length !== size) return `Must be exactly ${size} characters`
153
+ if (typeof value === 'number' && value !== size) return `Must be exactly ${size}`
154
+ if (Array.isArray(value) && value.length !== size) return `Must contain exactly ${size} items`
155
+ return null
156
+ },
157
+
158
+ 'required_if': (value, param, data) => {
159
+ if (!param) return null
160
+ const [field, ...rest] = param.split(',')
161
+ const expected = rest.join(',')
162
+ if (data?.[field] === expected && (value === undefined || value === null || value === '')) {
163
+ return 'This field is required'
164
+ }
165
+ return null
166
+ },
167
+ }
168
+
169
+ /**
170
+ * Validate data against string rules (CodeIgniter-style).
171
+ *
172
+ * @param data - The data to validate (usually `this.body`)
173
+ * @param rules - Object mapping field names to pipe-separated rule strings
174
+ * @returns ValidationResult
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * const v = validateStringRules(this.body, {
179
+ * name: 'required|min:2|max:100',
180
+ * email: 'required|email',
181
+ * age: 'numeric|min:18',
182
+ * })
183
+ * ```
184
+ */
185
+ export function validateStringRules<T extends Record<string, any>>(
186
+ data: T,
187
+ rules: Rules
188
+ ): ValidationResult<T> {
189
+ const errors: ValidationErrors = {}
190
+ const validData: Record<string, any> = { ...data }
191
+
192
+ for (const [field, ruleString] of Object.entries(rules)) {
193
+ const ruleList = ruleString.split('|').map(r => r.trim()).filter(Boolean)
194
+ const value = data[field]
195
+
196
+ for (const ruleDef of ruleList) {
197
+ const [ruleName, ...paramParts] = ruleDef.split(':')
198
+ const param = paramParts.join(':')
199
+
200
+ const ruleFn = RULES[ruleName]
201
+ if (!ruleFn) continue
202
+
203
+ const error = ruleFn(value, param, data)
204
+ if (error) {
205
+ (errors[field] ??= []).push(error)
206
+ }
207
+ }
208
+
209
+ // Trim values
210
+ if (typeof validData[field] === 'string') {
211
+ validData[field] = validData[field].trim()
212
+ }
213
+ }
214
+
215
+ return new ValidationResult<T>(
216
+ Object.keys(errors).length === 0,
217
+ validData as T,
218
+ errors
219
+ )
220
+ }
221
+
222
+ /**
223
+ * Validate data against a Zod schema.
224
+ *
225
+ * @param data - The data to validate
226
+ * @param schema - Zod schema
227
+ * @returns ValidationResult
228
+ *
229
+ * @example
230
+ * ```ts
231
+ * const schema = z.object({ name: z.string().min(2), email: z.string().email() })
232
+ * const v = validateZod(this.body, schema)
233
+ * ```
234
+ */
235
+ export function validateZod<T>(
236
+ data: unknown,
237
+ schema: z.ZodSchema<T>
238
+ ): ValidationResult<T> {
239
+ const result = schema.safeParse(data)
240
+ if (result.success) {
241
+ return new ValidationResult(true, result.data, {})
242
+ }
243
+
244
+ const errors: ValidationErrors = {}
245
+ for (const issue of result.error.issues) {
246
+ const path = issue.path.join('.')
247
+ ;(errors[path] ??= []).push(issue.message)
248
+ }
249
+ return new ValidationResult(false, data as T, errors)
250
+ }
251
+
252
+ /**
253
+ * Universal validate — auto-detects string rules vs Zod schema.
254
+ */
255
+ export function validate<T extends Record<string, any>>(
256
+ data: unknown,
257
+ schemaOrRules: z.ZodSchema<T> | Rules
258
+ ): ValidationResult<T> {
259
+ // Detect Zod schema (it's an object with safeParse method)
260
+ if (schemaOrRules && typeof schemaOrRules === 'object' && 'safeParse' in schemaOrRules) {
261
+ return validateZod(data, schemaOrRules as unknown as z.ZodSchema<T>)
262
+ }
263
+ return validateStringRules(data as Record<string, any>, schemaOrRules as Rules) as unknown as ValidationResult<T>
264
+ }
265
+
266
+ /** Rule names for autocomplete. */
267
+ export const rules = {
268
+ required: 'required',
269
+ min: (n: number) => `min:${n}`,
270
+ max: (n: number) => `max:${n}`,
271
+ email: 'email',
272
+ numeric: 'numeric',
273
+ integer: 'integer',
274
+ boolean: 'boolean',
275
+ url: 'url',
276
+ alpha: 'alpha',
277
+ alphaNum: 'alpha_num',
278
+ alphaDash: 'alpha_dash',
279
+ same: (field: string) => `same:${field}`,
280
+ differs: (field: string) => `differs:${field}`,
281
+ regex: (pattern: string) => `regex:${pattern}`,
282
+ date: 'date',
283
+ after: (date: string) => `after:${date}`,
284
+ before: (date: string) => `before:${date}`,
285
+ size: (n: number) => `size:${n}`,
286
+ requiredIf: (field: string, value: string) => `required_if:${field},${value}`,
287
+ } as const