@hy_ong/zod-kit 0.0.5 → 0.0.6

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.
@@ -1,7 +1,31 @@
1
+ /**
2
+ * @fileoverview Email validator for Zod Kit
3
+ *
4
+ * Provides comprehensive email validation with domain filtering, business email
5
+ * validation, disposable email detection, and extensive customization options.
6
+ *
7
+ * @author Ong Hoe Yuan
8
+ * @version 0.0.5
9
+ */
10
+
1
11
  import { z, ZodNullable, ZodString } from "zod"
2
12
  import { t } from "../../i18n"
3
13
  import { getLocale, type Locale } from "../../config"
4
14
 
15
+ /**
16
+ * Type definition for email validation error messages
17
+ *
18
+ * @interface EmailMessages
19
+ * @property {string} [required] - Message when field is required but empty
20
+ * @property {string} [invalid] - Message when email format is invalid
21
+ * @property {string} [minLength] - Message when email is too short
22
+ * @property {string} [maxLength] - Message when email is too long
23
+ * @property {string} [includes] - Message when email doesn't contain required string
24
+ * @property {string} [domain] - Message when email domain is not allowed
25
+ * @property {string} [domainBlacklist] - Message when email domain is blacklisted
26
+ * @property {string} [businessOnly] - Message when free email providers are not allowed
27
+ * @property {string} [noDisposable] - Message when disposable email addresses are not allowed
28
+ */
5
29
  export type EmailMessages = {
6
30
  required?: string
7
31
  invalid?: string
@@ -14,6 +38,27 @@ export type EmailMessages = {
14
38
  noDisposable?: string
15
39
  }
16
40
 
41
+ /**
42
+ * Configuration options for email validation
43
+ *
44
+ * @template IsRequired - Whether the field is required (affects return type)
45
+ *
46
+ * @interface EmailOptions
47
+ * @property {IsRequired} [required=true] - Whether the field is required
48
+ * @property {string | string[]} [domain] - Allowed domain(s) for email addresses
49
+ * @property {string[]} [domainBlacklist] - Domains that are not allowed
50
+ * @property {number} [minLength] - Minimum length of email address
51
+ * @property {number} [maxLength] - Maximum length of email address
52
+ * @property {string} [includes] - String that must be included in the email
53
+ * @property {string | string[]} [excludes] - String(s) that must not be included
54
+ * @property {boolean} [allowSubdomains=true] - Whether to allow subdomains in domain validation
55
+ * @property {boolean} [businessOnly=false] - If true, reject common free email providers
56
+ * @property {boolean} [noDisposable=false] - If true, reject disposable email addresses
57
+ * @property {boolean} [lowercase=true] - Whether to convert email to lowercase
58
+ * @property {Function} [transform] - Custom transformation function for email strings
59
+ * @property {string | null} [defaultValue] - Default value when input is empty
60
+ * @property {Record<Locale, EmailMessages>} [i18n] - Custom error messages for different locales
61
+ */
17
62
  export type EmailOptions<IsRequired extends boolean = true> = {
18
63
  required?: IsRequired
19
64
  domain?: string | string[]
@@ -31,8 +76,76 @@ export type EmailOptions<IsRequired extends boolean = true> = {
31
76
  i18n?: Record<Locale, EmailMessages>
32
77
  }
33
78
 
79
+ /**
80
+ * Type alias for email validation schema based on required flag
81
+ *
82
+ * @template IsRequired - Whether the field is required
83
+ * @typedef EmailSchema
84
+ * @description Returns ZodString if required, ZodNullable<ZodString> if optional
85
+ */
34
86
  export type EmailSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
35
87
 
88
+ /**
89
+ * Creates a Zod schema for email validation with comprehensive filtering options
90
+ *
91
+ * @template IsRequired - Whether the field is required (affects return type)
92
+ * @param {EmailOptions<IsRequired>} [options] - Configuration options for email validation
93
+ * @returns {EmailSchema<IsRequired>} Zod schema for email validation
94
+ *
95
+ * @description
96
+ * Creates a comprehensive email validator with domain filtering, business email
97
+ * validation, disposable email detection, and extensive customization options.
98
+ *
99
+ * Features:
100
+ * - RFC-compliant email format validation
101
+ * - Domain whitelist/blacklist support
102
+ * - Business email validation (excludes free providers)
103
+ * - Disposable email detection
104
+ * - Subdomain support configuration
105
+ * - Length validation
106
+ * - Content inclusion/exclusion
107
+ * - Automatic lowercase conversion
108
+ * - Custom transformation functions
109
+ * - Comprehensive internationalization
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * // Basic email validation
114
+ * const basicSchema = email()
115
+ * basicSchema.parse("user@example.com") // ✓ Valid
116
+ *
117
+ * // Domain restriction
118
+ * const domainSchema = email({
119
+ * domain: ["company.com", "organization.org"]
120
+ * })
121
+ * domainSchema.parse("user@company.com") // ✓ Valid
122
+ * domainSchema.parse("user@gmail.com") // ✗ Invalid
123
+ *
124
+ * // Business emails only (no free providers)
125
+ * const businessSchema = email({ businessOnly: true })
126
+ * businessSchema.parse("user@company.com") // ✓ Valid
127
+ * businessSchema.parse("user@gmail.com") // ✗ Invalid
128
+ *
129
+ * // No disposable emails
130
+ * const noDisposableSchema = email({ noDisposable: true })
131
+ * noDisposableSchema.parse("user@company.com") // ✓ Valid
132
+ * noDisposableSchema.parse("user@10minutemail.com") // ✗ Invalid
133
+ *
134
+ * // Domain blacklist
135
+ * const blacklistSchema = email({
136
+ * domainBlacklist: ["spam.com", "blocked.org"]
137
+ * })
138
+ *
139
+ * // Optional with default
140
+ * const optionalSchema = email({
141
+ * required: false,
142
+ * defaultValue: null
143
+ * })
144
+ * ```
145
+ *
146
+ * @throws {z.ZodError} When validation fails with specific error messages
147
+ * @see {@link EmailOptions} for all available configuration options
148
+ */
36
149
  export function email<IsRequired extends boolean = true>(options?: EmailOptions<IsRequired>): EmailSchema<IsRequired> {
37
150
  const {
38
151
  required = true,
@@ -0,0 +1,384 @@
1
+ /**
2
+ * @fileoverview File validator for Zod Kit
3
+ *
4
+ * Provides comprehensive file validation with MIME type filtering, size validation,
5
+ * extension validation, and extensive customization options.
6
+ *
7
+ * @author Ong Hoe Yuan
8
+ * @version 0.0.5
9
+ */
10
+
11
+ import { z, ZodNullable, ZodType } from "zod"
12
+ import { t } from "../../i18n"
13
+ import { getLocale, type Locale } from "../../config"
14
+
15
+ /**
16
+ * Type definition for file validation error messages
17
+ *
18
+ * @interface FileMessages
19
+ * @property {string} [required] - Message when field is required but empty
20
+ * @property {string} [invalid] - Message when file format is invalid
21
+ * @property {string} [size] - Message when file size exceeds limit
22
+ * @property {string} [minSize] - Message when file size is too small
23
+ * @property {string} [maxSize] - Message when file size exceeds maximum
24
+ * @property {string} [type] - Message when file type is not allowed
25
+ * @property {string} [extension] - Message when file extension is not allowed
26
+ * @property {string} [extensionBlacklist] - Message when file extension is blacklisted
27
+ * @property {string} [name] - Message when file name doesn't match pattern
28
+ * @property {string} [nameBlacklist] - Message when file name matches blacklisted pattern
29
+ * @property {string} [imageOnly] - Message when only image files are allowed
30
+ * @property {string} [documentOnly] - Message when only document files are allowed
31
+ * @property {string} [videoOnly] - Message when only video files are allowed
32
+ * @property {string} [audioOnly] - Message when only audio files are allowed
33
+ * @property {string} [archiveOnly] - Message when only archive files are allowed
34
+ */
35
+ export type FileMessages = {
36
+ required?: string
37
+ invalid?: string
38
+ size?: string
39
+ minSize?: string
40
+ maxSize?: string
41
+ type?: string
42
+ extension?: string
43
+ extensionBlacklist?: string
44
+ name?: string
45
+ nameBlacklist?: string
46
+ imageOnly?: string
47
+ documentOnly?: string
48
+ videoOnly?: string
49
+ audioOnly?: string
50
+ archiveOnly?: string
51
+ }
52
+
53
+ /**
54
+ * Configuration options for file validation
55
+ *
56
+ * @template IsRequired - Whether the field is required (affects return type)
57
+ *
58
+ * @interface FileOptions
59
+ * @property {IsRequired} [required=true] - Whether the field is required
60
+ * @property {number} [maxSize] - Maximum file size in bytes
61
+ * @property {number} [minSize] - Minimum file size in bytes
62
+ * @property {string | string[]} [type] - Allowed MIME type(s)
63
+ * @property {string[]} [typeBlacklist] - MIME types that are not allowed
64
+ * @property {string | string[]} [extension] - Allowed file extension(s)
65
+ * @property {string[]} [extensionBlacklist] - File extensions that are not allowed
66
+ * @property {RegExp | string} [namePattern] - Pattern that file name must match
67
+ * @property {RegExp | string | Array<RegExp | string>} [nameBlacklist] - Pattern(s) that file name must not match
68
+ * @property {boolean} [imageOnly=false] - If true, only allow image files
69
+ * @property {boolean} [documentOnly=false] - If true, only allow document files
70
+ * @property {boolean} [videoOnly=false] - If true, only allow video files
71
+ * @property {boolean} [audioOnly=false] - If true, only allow audio files
72
+ * @property {boolean} [archiveOnly=false] - If true, only allow archive files
73
+ * @property {boolean} [caseSensitive=false] - Whether extension matching is case-sensitive
74
+ * @property {Function} [transform] - Custom transformation function for File objects
75
+ * @property {File | null} [defaultValue] - Default value when input is empty
76
+ * @property {Record<Locale, FileMessages>} [i18n] - Custom error messages for different locales
77
+ */
78
+ export type FileOptions<IsRequired extends boolean = true> = {
79
+ required?: IsRequired
80
+ maxSize?: number
81
+ minSize?: number
82
+ type?: string | string[]
83
+ typeBlacklist?: string[]
84
+ extension?: string | string[]
85
+ extensionBlacklist?: string[]
86
+ namePattern?: RegExp | string
87
+ nameBlacklist?: RegExp | string | Array<RegExp | string>
88
+ imageOnly?: boolean
89
+ documentOnly?: boolean
90
+ videoOnly?: boolean
91
+ audioOnly?: boolean
92
+ archiveOnly?: boolean
93
+ caseSensitive?: boolean
94
+ transform?: (value: File) => File
95
+ defaultValue?: IsRequired extends true ? File : File | null
96
+ i18n?: Record<Locale, FileMessages>
97
+ }
98
+
99
+ /**
100
+ * Type alias for file validation schema based on required flag
101
+ *
102
+ * @template IsRequired - Whether the field is required
103
+ * @typedef FileSchema
104
+ * @description Returns ZodType<File> if required, ZodNullable<ZodType<File>> if optional
105
+ */
106
+ export type FileSchema<IsRequired extends boolean> = IsRequired extends true ? ZodType<File> : ZodNullable<ZodType<File>>
107
+
108
+ /**
109
+ * Creates a Zod schema for file validation with comprehensive filtering options
110
+ *
111
+ * @template IsRequired - Whether the field is required (affects return type)
112
+ * @param {FileOptions<IsRequired>} [options] - Configuration options for file validation
113
+ * @returns {FileSchema<IsRequired>} Zod schema for file validation
114
+ *
115
+ * @description
116
+ * Creates a comprehensive file validator with MIME type filtering, size validation,
117
+ * extension validation, and extensive customization options.
118
+ *
119
+ * Features:
120
+ * - File size validation (min/max)
121
+ * - MIME type whitelist/blacklist support
122
+ * - File extension whitelist/blacklist support
123
+ * - File name pattern validation
124
+ * - Predefined file category filters (image, document, video, audio, archive)
125
+ * - Case-sensitive/insensitive extension matching
126
+ * - Custom transformation functions
127
+ * - Comprehensive internationalization
128
+ *
129
+ * @example
130
+ * ```typescript
131
+ * // Basic file validation
132
+ * const basicSchema = file()
133
+ * basicSchema.parse(new File(["content"], "test.txt"))
134
+ *
135
+ * // Size restrictions
136
+ * const sizeSchema = file({
137
+ * maxSize: 1024 * 1024, // 1MB
138
+ * minSize: 1024 // 1KB
139
+ * })
140
+ *
141
+ * // Extension restrictions
142
+ * const imageSchema = file({
143
+ * extension: [".jpg", ".png", ".gif"],
144
+ * maxSize: 5 * 1024 * 1024 // 5MB
145
+ * })
146
+ *
147
+ * // MIME type restrictions
148
+ * const documentSchema = file({
149
+ * type: ["application/pdf", "application/msword"],
150
+ * maxSize: 10 * 1024 * 1024 // 10MB
151
+ * })
152
+ *
153
+ * // Image files only
154
+ * const imageOnlySchema = file({ imageOnly: true })
155
+ *
156
+ * // Document files only
157
+ * const docOnlySchema = file({ documentOnly: true })
158
+ *
159
+ * // Name pattern validation
160
+ * const patternSchema = file({
161
+ * namePattern: /^[a-zA-Z0-9_-]+\.(pdf|doc|docx)$/,
162
+ * maxSize: 5 * 1024 * 1024
163
+ * })
164
+ *
165
+ * // Optional with default
166
+ * const optionalSchema = file({
167
+ * required: false,
168
+ * defaultValue: null
169
+ * })
170
+ * ```
171
+ *
172
+ * @throws {z.ZodError} When validation fails with specific error messages
173
+ * @see {@link FileOptions} for all available configuration options
174
+ */
175
+ export function file<IsRequired extends boolean = true>(options?: FileOptions<IsRequired>): FileSchema<IsRequired> {
176
+ const {
177
+ required = true,
178
+ maxSize,
179
+ minSize,
180
+ type,
181
+ typeBlacklist,
182
+ extension,
183
+ extensionBlacklist,
184
+ namePattern,
185
+ nameBlacklist,
186
+ imageOnly = false,
187
+ documentOnly = false,
188
+ videoOnly = false,
189
+ audioOnly = false,
190
+ archiveOnly = false,
191
+ caseSensitive = false,
192
+ transform,
193
+ defaultValue,
194
+ i18n,
195
+ } = options ?? {}
196
+
197
+ // Helper function to get custom message or fallback to default i18n
198
+ const getMessage = (key: keyof FileMessages, params?: Record<string, any>) => {
199
+ if (i18n) {
200
+ const currentLocale = getLocale()
201
+ const customMessages = i18n[currentLocale]
202
+ if (customMessages && customMessages[key]) {
203
+ const template = customMessages[key]!
204
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
205
+ }
206
+ }
207
+ return t(`common.file.${key}`, params)
208
+ }
209
+
210
+ // Predefined file type categories
211
+ const imageTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp", "image/tiff"]
212
+ const documentTypes = [
213
+ "application/pdf",
214
+ "application/msword",
215
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
216
+ "application/vnd.ms-excel",
217
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
218
+ "application/vnd.ms-powerpoint",
219
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
220
+ "text/plain",
221
+ "text/csv",
222
+ ]
223
+ const videoTypes = ["video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo", "video/x-ms-wmv", "video/webm", "video/ogg"]
224
+ const audioTypes = ["audio/mpeg", "audio/wav", "audio/ogg", "audio/aac", "audio/webm", "audio/mp3", "audio/x-wav"]
225
+ const archiveTypes = ["application/zip", "application/x-rar-compressed", "application/x-7z-compressed", "application/x-tar", "application/gzip"]
226
+
227
+ const actualDefaultValue = defaultValue ?? null
228
+
229
+ const baseSchema = z.preprocess(
230
+ (val) => {
231
+ if (val === "" || val === null || val === undefined) {
232
+ return actualDefaultValue
233
+ }
234
+
235
+ if (!(val instanceof File)) {
236
+ return val
237
+ }
238
+
239
+ let processed = val
240
+
241
+ if (transform) {
242
+ processed = transform(processed)
243
+ }
244
+
245
+ return processed
246
+ },
247
+ z.union([z.instanceof(File).refine(() => true, { message: getMessage("invalid") }), z.null()])
248
+ )
249
+
250
+ const schema = baseSchema
251
+ .refine((val) => required === false || val !== null, {
252
+ message: getMessage("required"),
253
+ })
254
+ .refine((val) => val === null || val instanceof File, {
255
+ message: getMessage("invalid"),
256
+ })
257
+ .refine((val) => val === null || minSize === undefined || val.size >= minSize, {
258
+ message: getMessage("minSize", { minSize: formatFileSize(minSize || 0) }),
259
+ })
260
+ .refine((val) => val === null || maxSize === undefined || val.size <= maxSize, {
261
+ message: getMessage("maxSize", { maxSize: formatFileSize(maxSize || 0) }),
262
+ })
263
+ .refine((val) => val === null || !imageOnly || imageTypes.includes(val.type), {
264
+ message: getMessage("imageOnly"),
265
+ })
266
+ .refine((val) => val === null || !documentOnly || documentTypes.includes(val.type), {
267
+ message: getMessage("documentOnly"),
268
+ })
269
+ .refine((val) => val === null || !videoOnly || videoTypes.includes(val.type), {
270
+ message: getMessage("videoOnly"),
271
+ })
272
+ .refine((val) => val === null || !audioOnly || audioTypes.includes(val.type), {
273
+ message: getMessage("audioOnly"),
274
+ })
275
+ .refine((val) => val === null || !archiveOnly || archiveTypes.includes(val.type), {
276
+ message: getMessage("archiveOnly"),
277
+ })
278
+ .refine(
279
+ (val) => {
280
+ if (val === null || !typeBlacklist || typeBlacklist.length === 0) return true
281
+ return !typeBlacklist.includes(val.type)
282
+ },
283
+ {
284
+ message: getMessage("type", { type: typeBlacklist?.join(", ") || "" }),
285
+ }
286
+ )
287
+ .refine(
288
+ (val) => {
289
+ if (val === null || type === undefined) return true
290
+ const allowedTypes = Array.isArray(type) ? type : [type]
291
+ return allowedTypes.includes(val.type)
292
+ },
293
+ {
294
+ message: getMessage("type", { type: Array.isArray(type) ? type.join(", ") : type || "" }),
295
+ }
296
+ )
297
+ .refine(
298
+ (val) => {
299
+ if (val === null || extensionBlacklist === undefined || extensionBlacklist.length === 0) return true
300
+ const fileExtension = getFileExtension(val.name, caseSensitive)
301
+ const normalizedBlacklist = extensionBlacklist.map((ext) => normalizeExtension(ext, caseSensitive))
302
+ return !normalizedBlacklist.includes(fileExtension)
303
+ },
304
+ {
305
+ message: getMessage("extensionBlacklist", { extension: extensionBlacklist?.join(", ") || "" }),
306
+ }
307
+ )
308
+ .refine(
309
+ (val) => {
310
+ if (val === null || extension === undefined) return true
311
+ const fileName = val.name
312
+ const fileExtension = getFileExtension(fileName, caseSensitive)
313
+ const allowedExtensions = Array.isArray(extension) ? extension : [extension]
314
+ const normalizedExtensions = allowedExtensions.map((ext) => normalizeExtension(ext, caseSensitive))
315
+ return normalizedExtensions.includes(fileExtension)
316
+ },
317
+ {
318
+ message: getMessage("extension", { extension: Array.isArray(extension) ? extension.join(", ") : extension || "" }),
319
+ }
320
+ )
321
+ .refine(
322
+ (val) => {
323
+ if (val === null || namePattern === undefined) return true
324
+ const pattern = typeof namePattern === "string" ? new RegExp(namePattern) : namePattern
325
+ return pattern.test(val.name)
326
+ },
327
+ {
328
+ message: getMessage("name", { pattern: namePattern?.toString() || "" }),
329
+ }
330
+ )
331
+ .refine(
332
+ (val) => {
333
+ if (val === null || nameBlacklist === undefined) return true
334
+ const blacklistPatterns = Array.isArray(nameBlacklist) ? nameBlacklist : [nameBlacklist]
335
+ for (const blacklistPattern of blacklistPatterns) {
336
+ const pattern = typeof blacklistPattern === "string" ? new RegExp(blacklistPattern) : blacklistPattern
337
+ if (pattern.test(val.name)) {
338
+ return false
339
+ }
340
+ }
341
+ return true
342
+ },
343
+ {
344
+ message: getMessage("nameBlacklist", { pattern: "" }),
345
+ }
346
+ )
347
+
348
+ return schema as unknown as FileSchema<IsRequired>
349
+ }
350
+
351
+ /**
352
+ * Helper function to get file extension
353
+ */
354
+ function getFileExtension(fileName: string, caseSensitive: boolean): string {
355
+ const lastDotIndex = fileName.lastIndexOf(".")
356
+ if (lastDotIndex === -1) return ""
357
+
358
+ const extension = fileName.substring(lastDotIndex)
359
+ return caseSensitive ? extension : extension.toLowerCase()
360
+ }
361
+
362
+ /**
363
+ * Helper function to normalize extension for comparison
364
+ */
365
+ function normalizeExtension(extension: string, caseSensitive: boolean): string {
366
+ const normalized = extension.startsWith(".") ? extension : `.${extension}`
367
+ return caseSensitive ? normalized : normalized.toLowerCase()
368
+ }
369
+
370
+ /**
371
+ * Helper function to format file size in human-readable format
372
+ */
373
+ function formatFileSize(bytes: number): string {
374
+ const units = ["B", "KB", "MB", "GB", "TB"]
375
+ let size = bytes
376
+ let unitIndex = 0
377
+
378
+ while (size >= 1024 && unitIndex < units.length - 1) {
379
+ size /= 1024
380
+ unitIndex++
381
+ }
382
+
383
+ return `${Math.round(size * 100) / 100} ${units[unitIndex]}`
384
+ }