@hy_ong/zod-kit 0.0.5 → 0.1.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 (46) hide show
  1. package/.claude/settings.local.json +9 -1
  2. package/README.md +465 -97
  3. package/dist/index.cjs +1690 -179
  4. package/dist/index.d.cts +2791 -28
  5. package/dist/index.d.ts +2791 -28
  6. package/dist/index.js +1672 -178
  7. package/package.json +2 -1
  8. package/src/i18n/locales/en.json +62 -0
  9. package/src/i18n/locales/zh-TW.json +62 -0
  10. package/src/index.ts +4 -0
  11. package/src/validators/common/boolean.ts +101 -4
  12. package/src/validators/common/date.ts +141 -6
  13. package/src/validators/common/datetime.ts +680 -0
  14. package/src/validators/common/email.ts +120 -4
  15. package/src/validators/common/file.ts +391 -0
  16. package/src/validators/common/id.ts +230 -18
  17. package/src/validators/common/number.ts +132 -4
  18. package/src/validators/common/password.ts +187 -8
  19. package/src/validators/common/text.ts +130 -6
  20. package/src/validators/common/time.ts +607 -0
  21. package/src/validators/common/url.ts +153 -6
  22. package/src/validators/taiwan/business-id.ts +138 -9
  23. package/src/validators/taiwan/fax.ts +164 -10
  24. package/src/validators/taiwan/mobile.ts +151 -10
  25. package/src/validators/taiwan/national-id.ts +233 -17
  26. package/src/validators/taiwan/postal-code.ts +1048 -0
  27. package/src/validators/taiwan/tel.ts +167 -10
  28. package/tests/common/boolean.test.ts +38 -38
  29. package/tests/common/date.test.ts +65 -65
  30. package/tests/common/datetime.test.ts +675 -0
  31. package/tests/common/email.test.ts +24 -28
  32. package/tests/common/file.test.ts +475 -0
  33. package/tests/common/id.test.ts +80 -113
  34. package/tests/common/number.test.ts +24 -25
  35. package/tests/common/password.test.ts +28 -35
  36. package/tests/common/text.test.ts +36 -37
  37. package/tests/common/time.test.ts +510 -0
  38. package/tests/common/url.test.ts +67 -67
  39. package/tests/taiwan/business-id.test.ts +22 -22
  40. package/tests/taiwan/fax.test.ts +33 -42
  41. package/tests/taiwan/mobile.test.ts +32 -41
  42. package/tests/taiwan/national-id.test.ts +31 -31
  43. package/tests/taiwan/postal-code.test.ts +751 -0
  44. package/tests/taiwan/tel.test.ts +33 -42
  45. package/debug.js +0 -21
  46. package/debug.ts +0 -16
@@ -0,0 +1,680 @@
1
+ /**
2
+ * @fileoverview DateTime validator for Zod Kit
3
+ *
4
+ * Provides comprehensive datetime validation with support for multiple formats,
5
+ * timezone handling, range validation, and internationalization.
6
+ *
7
+ * @author Ong Hoe Yuan
8
+ * @version 0.0.5
9
+ */
10
+
11
+ import { z, ZodNullable, ZodString } from "zod"
12
+ import { t } from "../../i18n"
13
+ import { getLocale, type Locale } from "../../config"
14
+ import dayjs from "dayjs"
15
+ import customParseFormat from "dayjs/plugin/customParseFormat"
16
+ import isSameOrAfter from "dayjs/plugin/isSameOrAfter"
17
+ import isSameOrBefore from "dayjs/plugin/isSameOrBefore"
18
+ import isToday from "dayjs/plugin/isToday"
19
+ import weekday from "dayjs/plugin/weekday"
20
+ import timezone from "dayjs/plugin/timezone"
21
+ import utc from "dayjs/plugin/utc"
22
+
23
+ // Initialize dayjs plugins for extended functionality
24
+ dayjs.extend(isSameOrAfter)
25
+ dayjs.extend(isSameOrBefore)
26
+ dayjs.extend(customParseFormat)
27
+ dayjs.extend(isToday)
28
+ dayjs.extend(weekday)
29
+ dayjs.extend(timezone)
30
+ dayjs.extend(utc)
31
+
32
+ /**
33
+ * Type definition for datetime validation error messages
34
+ *
35
+ * @interface DateTimeMessages
36
+ * @property {string} [required] - Message when field is required but empty
37
+ * @property {string} [invalid] - Message when datetime format is invalid
38
+ * @property {string} [format] - Message when datetime doesn't match expected format
39
+ * @property {string} [min] - Message when datetime is before minimum allowed
40
+ * @property {string} [max] - Message when datetime is after maximum allowed
41
+ * @property {string} [includes] - Message when datetime doesn't include required string
42
+ * @property {string} [excludes] - Message when datetime contains excluded string
43
+ * @property {string} [past] - Message when datetime must be in the past
44
+ * @property {string} [future] - Message when datetime must be in the future
45
+ * @property {string} [today] - Message when datetime must be today
46
+ * @property {string} [notToday] - Message when datetime must not be today
47
+ * @property {string} [weekday] - Message when datetime must be a weekday
48
+ * @property {string} [weekend] - Message when datetime must be a weekend
49
+ * @property {string} [hour] - Message when hour is outside allowed range
50
+ * @property {string} [minute] - Message when minute doesn't match step requirement
51
+ * @property {string} [customRegex] - Message when custom regex validation fails
52
+ * @property {string} [notInWhitelist] - Message when value is not in whitelist
53
+ */
54
+ export type DateTimeMessages = {
55
+ required?: string
56
+ invalid?: string
57
+ format?: string
58
+ min?: string
59
+ max?: string
60
+ includes?: string
61
+ excludes?: string
62
+ past?: string
63
+ future?: string
64
+ today?: string
65
+ notToday?: string
66
+ weekday?: string
67
+ weekend?: string
68
+ hour?: string
69
+ minute?: string
70
+ customRegex?: string
71
+ notInWhitelist?: string
72
+ }
73
+
74
+ /**
75
+ * Supported datetime formats for validation
76
+ *
77
+ * @typedef {string} DateTimeFormat
78
+ *
79
+ * Standard formats:
80
+ * - YYYY-MM-DD HH:mm: ISO-style date with 24-hour time (2024-03-15 14:30)
81
+ * - YYYY-MM-DD HH:mm:ss: ISO-style date with seconds (2024-03-15 14:30:45)
82
+ * - YYYY-MM-DD hh:mm A: ISO-style date with 12-hour time (2024-03-15 02:30 PM)
83
+ * - YYYY-MM-DD hh:mm:ss A: ISO-style date with 12-hour time and seconds (2024-03-15 02:30:45 PM)
84
+ *
85
+ * Regional formats:
86
+ * - DD/MM/YYYY HH:mm: European format (15/03/2024 14:30)
87
+ * - DD/MM/YYYY HH:mm:ss: European format with seconds (15/03/2024 14:30:45)
88
+ * - DD/MM/YYYY hh:mm A: European format with 12-hour time (15/03/2024 02:30 PM)
89
+ * - MM/DD/YYYY HH:mm: US format (03/15/2024 14:30)
90
+ * - MM/DD/YYYY hh:mm A: US format with 12-hour time (03/15/2024 02:30 PM)
91
+ * - YYYY/MM/DD HH:mm: Alternative slash format (2024/03/15 14:30)
92
+ * - DD-MM-YYYY HH:mm: European dash format (15-03-2024 14:30)
93
+ * - MM-DD-YYYY HH:mm: US dash format (03-15-2024 14:30)
94
+ *
95
+ * Special formats:
96
+ * - ISO: ISO 8601 format (2024-03-15T14:30:45.000Z)
97
+ * - RFC: RFC 2822 format (Fri, 15 Mar 2024 14:30:45 GMT)
98
+ * - UNIX: Unix timestamp (1710508245)
99
+ */
100
+ export type DateTimeFormat =
101
+ | "YYYY-MM-DD HH:mm" // 2024-03-15 14:30
102
+ | "YYYY-MM-DD HH:mm:ss" // 2024-03-15 14:30:45
103
+ | "YYYY-MM-DD hh:mm A" // 2024-03-15 02:30 PM
104
+ | "YYYY-MM-DD hh:mm:ss A" // 2024-03-15 02:30:45 PM
105
+ | "DD/MM/YYYY HH:mm" // 15/03/2024 14:30
106
+ | "DD/MM/YYYY HH:mm:ss" // 15/03/2024 14:30:45
107
+ | "DD/MM/YYYY hh:mm A" // 15/03/2024 02:30 PM
108
+ | "MM/DD/YYYY HH:mm" // 03/15/2024 14:30
109
+ | "MM/DD/YYYY hh:mm A" // 03/15/2024 02:30 PM
110
+ | "YYYY/MM/DD HH:mm" // 2024/03/15 14:30
111
+ | "DD-MM-YYYY HH:mm" // 15-03-2024 14:30
112
+ | "MM-DD-YYYY HH:mm" // 03-15-2024 14:30
113
+ | "ISO" // ISO 8601: 2024-03-15T14:30:45.000Z
114
+ | "RFC" // RFC 2822: Fri, 15 Mar 2024 14:30:45 GMT
115
+ | "UNIX" // Unix timestamp: 1710508245
116
+
117
+ /**
118
+ * Configuration options for datetime validation
119
+ *
120
+ * @template IsRequired - Whether the field is required (affects return type)
121
+ *
122
+ * @interface DateTimeOptions
123
+ * @property {IsRequired} [required=true] - Whether the field is required
124
+ * @property {DateTimeFormat} [format="YYYY-MM-DD HH:mm"] - Expected datetime format
125
+ * @property {string | Date} [min] - Minimum allowed datetime
126
+ * @property {string | Date} [max] - Maximum allowed datetime
127
+ * @property {number} [minHour] - Minimum allowed hour (0-23)
128
+ * @property {number} [maxHour] - Maximum allowed hour (0-23)
129
+ * @property {number[]} [allowedHours] - Specific hours that are allowed
130
+ * @property {number} [minuteStep] - Required minute intervals (e.g., 15 for :00, :15, :30, :45)
131
+ * @property {string} [timezone] - Timezone for parsing and validation (e.g., "Asia/Taipei")
132
+ * @property {string} [includes] - String that must be included in the datetime
133
+ * @property {string | string[]} [excludes] - String(s) that must not be included
134
+ * @property {RegExp} [regex] - Custom regex for validation (overrides format validation)
135
+ * @property {"trim" | "trimStart" | "trimEnd" | "none"} [trimMode="trim"] - Whitespace handling
136
+ * @property {"upper" | "lower" | "none"} [casing="none"] - Case transformation
137
+ * @property {boolean} [mustBePast] - Whether datetime must be in the past
138
+ * @property {boolean} [mustBeFuture] - Whether datetime must be in the future
139
+ * @property {boolean} [mustBeToday] - Whether datetime must be today
140
+ * @property {boolean} [mustNotBeToday] - Whether datetime must not be today
141
+ * @property {boolean} [weekdaysOnly] - Whether datetime must be a weekday (Monday-Friday)
142
+ * @property {boolean} [weekendsOnly] - Whether datetime must be a weekend (Saturday-Sunday)
143
+ * @property {string[]} [whitelist] - Specific datetime strings that are always allowed
144
+ * @property {boolean} [whitelistOnly=false] - If true, only values in whitelist are allowed
145
+ * @property {Function} [transform] - Custom transformation function applied before validation
146
+ * @property {string | null} [defaultValue] - Default value when input is empty
147
+ * @property {Record<Locale, DateTimeMessages>} [i18n] - Custom error messages for different locales
148
+ */
149
+ export type DateTimeOptions<IsRequired extends boolean = true> = {
150
+ format?: DateTimeFormat
151
+ min?: string | Date // Minimum datetime
152
+ max?: string | Date // Maximum datetime
153
+ minHour?: number // Minimum hour (0-23)
154
+ maxHour?: number // Maximum hour (0-23)
155
+ allowedHours?: number[] // Specific hours allowed
156
+ minuteStep?: number // Minute intervals
157
+ timezone?: string // Timezone (e.g., "Asia/Taipei")
158
+ includes?: string // Must include specific substring
159
+ excludes?: string | string[] // Must not contain specific substring(s)
160
+ regex?: RegExp // Custom regex validation
161
+ trimMode?: "trim" | "trimStart" | "trimEnd" | "none" // Whitespace handling
162
+ casing?: "upper" | "lower" | "none" // Case transformation
163
+ mustBePast?: boolean // Must be in the past
164
+ mustBeFuture?: boolean // Must be in the future
165
+ mustBeToday?: boolean // Must be today
166
+ mustNotBeToday?: boolean // Must not be today
167
+ weekdaysOnly?: boolean // Only weekdays (Monday-Friday)
168
+ weekendsOnly?: boolean // Only weekends (Saturday-Sunday)
169
+ whitelist?: string[] // Allow specific datetime strings
170
+ whitelistOnly?: boolean // If true, only allow values in whitelist
171
+ transform?: (value: string) => string
172
+ defaultValue?: IsRequired extends true ? string : string | null
173
+ i18n?: Record<Locale, DateTimeMessages>
174
+ }
175
+
176
+ /**
177
+ * Type alias for datetime validation schema based on required flag
178
+ *
179
+ * @template IsRequired - Whether the field is required
180
+ * @typedef DateTimeSchema
181
+ * @description Returns ZodString if required, ZodNullable<ZodString> if optional
182
+ */
183
+ export type DateTimeSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
184
+
185
+ /**
186
+ * Regular expression patterns for datetime format validation
187
+ *
188
+ * @constant {Record<DateTimeFormat, RegExp>} DATETIME_PATTERNS
189
+ * @description Maps each supported datetime format to its corresponding regex pattern
190
+ *
191
+ * Pattern explanations:
192
+ * - YYYY-MM-DD HH:mm: 4-digit year, 2-digit month, 2-digit day, 24-hour time
193
+ * - ISO: ISO 8601 format with optional milliseconds and timezone
194
+ * - RFC: RFC 2822 format with day name, date, time, and timezone
195
+ * - UNIX: 10-digit Unix timestamp
196
+ */
197
+ const DATETIME_PATTERNS: Record<DateTimeFormat, RegExp> = {
198
+ "YYYY-MM-DD HH:mm": /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/,
199
+ "YYYY-MM-DD HH:mm:ss": /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
200
+ "YYYY-MM-DD hh:mm A": /^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2} (AM|PM)$/i,
201
+ "YYYY-MM-DD hh:mm:ss A": /^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}:\d{2} (AM|PM)$/i,
202
+ "DD/MM/YYYY HH:mm": /^\d{1,2}\/\d{1,2}\/\d{4} \d{2}:\d{2}$/,
203
+ "DD/MM/YYYY HH:mm:ss": /^\d{1,2}\/\d{1,2}\/\d{4} \d{2}:\d{2}:\d{2}$/,
204
+ "DD/MM/YYYY hh:mm A": /^\d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{2} (AM|PM)$/i,
205
+ "MM/DD/YYYY HH:mm": /^\d{1,2}\/\d{1,2}\/\d{4} \d{2}:\d{2}$/,
206
+ "MM/DD/YYYY hh:mm A": /^\d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{2} (AM|PM)$/i,
207
+ "YYYY/MM/DD HH:mm": /^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}$/,
208
+ "DD-MM-YYYY HH:mm": /^\d{1,2}-\d{1,2}-\d{4} \d{2}:\d{2}$/,
209
+ "MM-DD-YYYY HH:mm": /^\d{1,2}-\d{1,2}-\d{4} \d{2}:\d{2}$/,
210
+ ISO: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/,
211
+ RFC: /^[A-Za-z]{3}, \d{1,2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} [A-Z]{3}$/,
212
+ UNIX: /^\d{10}$/,
213
+ }
214
+
215
+ /**
216
+ * Validates if a datetime string matches the specified format pattern
217
+ *
218
+ * @param {string} value - The datetime string to validate
219
+ * @param {DateTimeFormat} format - The expected datetime format
220
+ * @returns {boolean} True if the datetime is valid for the given format
221
+ *
222
+ * @description
223
+ * Performs both regex pattern matching and actual datetime parsing validation.
224
+ * Returns false if either the pattern doesn't match or the parsed datetime is invalid.
225
+ *
226
+ * @example
227
+ * ```typescript
228
+ * validateDateTimeFormat("2024-03-15 14:30", "YYYY-MM-DD HH:mm") // true
229
+ * validateDateTimeFormat("2024-03-15 25:30", "YYYY-MM-DD HH:mm") // false (invalid hour)
230
+ * validateDateTimeFormat("15/03/2024", "YYYY-MM-DD HH:mm") // false (wrong format)
231
+ * ```
232
+ */
233
+ const validateDateTimeFormat = (value: string, format: DateTimeFormat): boolean => {
234
+ const pattern = DATETIME_PATTERNS[format]
235
+ if (!pattern.test(value.trim())) {
236
+ return false
237
+ }
238
+
239
+ // Additional validation: check if the datetime is actually valid
240
+ const parsed = parseDateTimeValue(value, format)
241
+ return parsed !== null
242
+ }
243
+
244
+ /**
245
+ * Parses a datetime string into a dayjs object using the specified format
246
+ *
247
+ * @param {string} value - The datetime string to parse
248
+ * @param {DateTimeFormat} format - The expected datetime format
249
+ * @param {string} [timezone] - Optional timezone for parsing (e.g., "Asia/Taipei")
250
+ * @returns {dayjs.Dayjs | null} Parsed dayjs object or null if parsing fails
251
+ *
252
+ * @description
253
+ * Handles different datetime formats including ISO, RFC, Unix timestamps, and custom formats.
254
+ * Uses strict parsing mode for custom formats to ensure accuracy.
255
+ * Applies timezone conversion if specified.
256
+ *
257
+ * @example
258
+ * ```typescript
259
+ * parseDateTimeValue("2024-03-15 14:30", "YYYY-MM-DD HH:mm")
260
+ * // Returns dayjs object for March 15, 2024 at 2:30 PM
261
+ *
262
+ * parseDateTimeValue("1710508245", "UNIX")
263
+ * // Returns dayjs object for the Unix timestamp
264
+ *
265
+ * parseDateTimeValue("2024-03-15T14:30:45.000Z", "ISO")
266
+ * // Returns dayjs object for the ISO datetime
267
+ * ```
268
+ *
269
+ * @throws {Error} Returns null if parsing fails or datetime is invalid
270
+ */
271
+ const parseDateTimeValue = (value: string, format: DateTimeFormat, timezone?: string): dayjs.Dayjs | null => {
272
+ try {
273
+ const cleanValue = value.trim()
274
+
275
+ let parsed: dayjs.Dayjs
276
+
277
+ switch (format) {
278
+ case "ISO":
279
+ parsed = dayjs(cleanValue)
280
+ break
281
+ case "RFC":
282
+ parsed = dayjs(cleanValue)
283
+ break
284
+ case "UNIX":
285
+ parsed = dayjs.unix(parseInt(cleanValue, 10))
286
+ break
287
+ default:
288
+ parsed = dayjs(cleanValue, format, true) // strict parsing
289
+ break
290
+ }
291
+
292
+ if (!parsed.isValid()) {
293
+ return null
294
+ }
295
+
296
+ // Apply timezone if specified
297
+ if (timezone) {
298
+ parsed = parsed.tz(timezone)
299
+ }
300
+
301
+ return parsed
302
+ } catch {
303
+ return null
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Normalizes a datetime string to the specified format
309
+ *
310
+ * @param {string} value - The datetime string to normalize
311
+ * @param {DateTimeFormat} format - The target datetime format
312
+ * @param {string} [timezone] - Optional timezone for formatting
313
+ * @returns {string | null} Normalized datetime string or null if parsing fails
314
+ *
315
+ * @description
316
+ * Parses the input datetime and formats it according to the specified format.
317
+ * Handles special formats like ISO, RFC, and Unix timestamps appropriately.
318
+ * Returns null if the input datetime cannot be parsed.
319
+ *
320
+ * @example
321
+ * ```typescript
322
+ * normalizeDateTimeValue("2024-3-15 2:30 PM", "YYYY-MM-DD HH:mm")
323
+ * // Returns "2024-03-15 14:30"
324
+ *
325
+ * normalizeDateTimeValue("1710508245", "ISO")
326
+ * // Returns "2024-03-15T14:30:45.000Z"
327
+ * ```
328
+ */
329
+ const normalizeDateTimeValue = (value: string, format: DateTimeFormat, timezone?: string): string | null => {
330
+ const parsed = parseDateTimeValue(value, format, timezone)
331
+ if (!parsed) return null
332
+
333
+ switch (format) {
334
+ case "ISO":
335
+ return parsed.toISOString()
336
+ case "RFC":
337
+ return parsed.format("ddd, DD MMM YYYY HH:mm:ss [GMT]")
338
+ case "UNIX":
339
+ return parsed.unix().toString()
340
+ default:
341
+ return parsed.format(format)
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Creates a Zod schema for datetime validation with comprehensive options
347
+ *
348
+ * @template IsRequired - Whether the field is required (affects return type)
349
+ * @param {IsRequired} [required=false] - Whether the field is required
350
+ * @param {Omit<ValidatorOptions<IsRequired>, 'required'>} [options] - Configuration options for validation
351
+ * @returns {DateTimeSchema<IsRequired>} Zod schema for datetime validation
352
+ *
353
+ * @description
354
+ * Creates a comprehensive datetime validator that supports multiple formats, timezone handling,
355
+ * range validation, temporal constraints, and extensive customization options.
356
+ *
357
+ * Features:
358
+ * - Multiple datetime formats (ISO, RFC, Unix, regional formats)
359
+ * - Timezone support and conversion
360
+ * - Range validation (min/max datetime)
361
+ * - Hour and minute constraints
362
+ * - Temporal validation (past/future/today)
363
+ * - Weekday/weekend validation
364
+ * - Whitelist/blacklist support
365
+ * - Custom regex patterns
366
+ * - String transformation and case handling
367
+ * - Comprehensive internationalization
368
+ *
369
+ * @example
370
+ * ```typescript
371
+ * // Basic datetime validation
372
+ * const basicSchema = datetime() // optional by default
373
+ * basicSchema.parse("2024-03-15 14:30") // ✓ Valid
374
+ * basicSchema.parse(null) // ✓ Valid (optional)
375
+ *
376
+ * // Required validation
377
+ * const requiredSchema = parse("2024-03-15 14:30") // ✓ Valid
378
+ (true)
379
+ * requiredSchema.parse(null) // ✗ Invalid (required)
380
+ *
381
+ *
382
+ * // Business hours validation
383
+ * const businessHours = datetime({
384
+ * format: "YYYY-MM-DD HH:mm",
385
+ * minHour: 9,
386
+ * maxHour: 17,
387
+ * weekdaysOnly: true
388
+ * })
389
+ *
390
+ * // Timezone-aware validation
391
+ * const timezoneSchema = datetime(false, {
392
+ * timezone: "Asia/Taipei",
393
+ * mustBeFuture: true
394
+ * })
395
+ *
396
+ * // Multiple format support
397
+ * const flexibleSchema = datetime(false, {
398
+ * format: "DD/MM/YYYY HH:mm"
399
+ * })
400
+ * flexibleSchema.parse("15/03/2024 14:30") // ✓ Valid
401
+ *
402
+ * // Optional with default
403
+ * const optionalSchema = datetime(false, {
404
+ * defaultValue: "2024-01-01 00:00"
405
+ * })
406
+ * ```
407
+ *
408
+ * @throws {z.ZodError} When validation fails with specific error messages
409
+ * @see {@link DateTimeOptions} for all available configuration options
410
+ * @see {@link DateTimeFormat} for supported datetime formats
411
+ */
412
+ export function datetime<IsRequired extends boolean = false>(required?: IsRequired, options?: Omit<DateTimeOptions<IsRequired>, 'required'>): DateTimeSchema<IsRequired> {
413
+ const {
414
+ format = "YYYY-MM-DD HH:mm",
415
+ min,
416
+ max,
417
+ minHour,
418
+ maxHour,
419
+ allowedHours,
420
+ minuteStep,
421
+ timezone,
422
+ includes,
423
+ excludes,
424
+ regex,
425
+ trimMode = "trim",
426
+ casing = "none",
427
+ mustBePast,
428
+ mustBeFuture,
429
+ mustBeToday,
430
+ mustNotBeToday,
431
+ weekdaysOnly,
432
+ weekendsOnly,
433
+ whitelist,
434
+ whitelistOnly = false,
435
+ transform,
436
+ defaultValue,
437
+ i18n,
438
+ } = options ?? {}
439
+
440
+ const isRequired = required ?? false as IsRequired
441
+
442
+ // Set appropriate default value based on required flag
443
+ const actualDefaultValue = defaultValue ?? (isRequired ? "" : null)
444
+
445
+ // Helper function to get custom message or fallback to default i18n
446
+ const getMessage = (key: keyof DateTimeMessages, params?: Record<string, any>) => {
447
+ if (i18n) {
448
+ const currentLocale = getLocale()
449
+ const customMessages = i18n[currentLocale]
450
+ if (customMessages && customMessages[key]) {
451
+ const template = customMessages[key]!
452
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
453
+ }
454
+ }
455
+ return t(`common.datetime.${key}`, params)
456
+ }
457
+
458
+ // Preprocessing function
459
+ const preprocessFn = (val: unknown) => {
460
+ if (val === null || val === undefined) {
461
+ return actualDefaultValue
462
+ }
463
+
464
+ let processed = String(val)
465
+
466
+ // Apply trim mode
467
+ switch (trimMode) {
468
+ case "trim":
469
+ processed = processed.trim()
470
+ break
471
+ case "trimStart":
472
+ processed = processed.trimStart()
473
+ break
474
+ case "trimEnd":
475
+ processed = processed.trimEnd()
476
+ break
477
+ case "none":
478
+ // No trimming
479
+ break
480
+ }
481
+
482
+ // If after trimming we have an empty string
483
+ if (processed === "") {
484
+ // If empty string is in whitelist, return it as is
485
+ if (whitelist && whitelist.includes("")) {
486
+ return ""
487
+ }
488
+ // If the field is optional and empty string not in whitelist, return default value
489
+ if (!isRequired) {
490
+ return actualDefaultValue
491
+ }
492
+ // If a field is required, return the default value (will be validated later)
493
+ return actualDefaultValue
494
+ }
495
+
496
+ // Apply case transformation
497
+ switch (casing) {
498
+ case "upper":
499
+ processed = processed.toUpperCase()
500
+ break
501
+ case "lower":
502
+ processed = processed.toLowerCase()
503
+ break
504
+ case "none":
505
+ // No case transformation
506
+ break
507
+ }
508
+
509
+ if (transform) {
510
+ processed = transform(processed)
511
+ }
512
+
513
+ return processed
514
+ }
515
+
516
+ const baseSchema = isRequired ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
517
+
518
+ const schema = baseSchema.refine((val) => {
519
+ if (val === null) return true
520
+
521
+ // Required check
522
+ if (isRequired && (val === "" || val === "null" || val === "undefined")) {
523
+ throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
524
+ }
525
+
526
+ if (val === null) return true
527
+ if (!isRequired && val === "") return true
528
+
529
+ // Whitelist check
530
+ if (whitelist && whitelist.length > 0) {
531
+ if (whitelist.includes(val)) {
532
+ return true
533
+ }
534
+ // If whitelistOnly is true, reject values not in whitelist
535
+ if (whitelistOnly) {
536
+ throw new z.ZodError([{ code: "custom", message: getMessage("notInWhitelist"), path: [] }])
537
+ }
538
+ // Otherwise, continue with normal validation
539
+ }
540
+
541
+ // Custom regex validation (overrides format validation)
542
+ if (regex) {
543
+ if (!regex.test(val)) {
544
+ throw new z.ZodError([{ code: "custom", message: getMessage("customRegex"), path: [] }])
545
+ }
546
+ } else {
547
+ // DateTime format validation (only if no regex is provided)
548
+ if (!validateDateTimeFormat(val, format)) {
549
+ throw new z.ZodError([{ code: "custom", message: getMessage("format", { format }), path: [] }])
550
+ }
551
+ }
552
+
553
+ // String content checks
554
+ if (includes && !val.includes(includes)) {
555
+ throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
556
+ }
557
+
558
+ if (excludes) {
559
+ const excludeList = Array.isArray(excludes) ? excludes : [excludes]
560
+ for (const exclude of excludeList) {
561
+ if (val.includes(exclude)) {
562
+ throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
563
+ }
564
+ }
565
+ }
566
+
567
+ // Skip datetime parsing and range validation if using custom regex
568
+ if (regex) {
569
+ return true
570
+ }
571
+
572
+ // Parse datetime for validation
573
+ const parsed = parseDateTimeValue(val, format, timezone)
574
+ if (!parsed) {
575
+ // Check if it's a format issue or parsing issue
576
+ const pattern = DATETIME_PATTERNS[format]
577
+ if (!pattern.test(val.trim())) {
578
+ throw new z.ZodError([{ code: "custom", message: getMessage("format", { format }), path: [] }])
579
+ } else {
580
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
581
+ }
582
+ }
583
+
584
+ // Hour validation
585
+ const hour = parsed.hour()
586
+ if (minHour !== undefined && hour < minHour) {
587
+ throw new z.ZodError([{ code: "custom", message: getMessage("hour", { minHour, maxHour: maxHour ?? 23 }), path: [] }])
588
+ }
589
+ if (maxHour !== undefined && hour > maxHour) {
590
+ throw new z.ZodError([{ code: "custom", message: getMessage("hour", { minHour: minHour ?? 0, maxHour }), path: [] }])
591
+ }
592
+
593
+ // Allowed hours check
594
+ if (allowedHours && allowedHours.length > 0) {
595
+ if (!allowedHours.includes(hour)) {
596
+ throw new z.ZodError([{ code: "custom", message: getMessage("hour", { allowedHours: allowedHours.join(", ") }), path: [] }])
597
+ }
598
+ }
599
+
600
+ // Minute step validation
601
+ const minute = parsed.minute()
602
+ if (minuteStep !== undefined && minute % minuteStep !== 0) {
603
+ throw new z.ZodError([{ code: "custom", message: getMessage("minute", { minuteStep }), path: [] }])
604
+ }
605
+
606
+ // DateTime range validation (min/max)
607
+ if (min) {
608
+ const minParsed = typeof min === "string" ? parseDateTimeValue(min, format, timezone) : dayjs(min)
609
+ if (minParsed && parsed.isBefore(minParsed)) {
610
+ const minFormatted = typeof min === "string" ? min : minParsed.format(format)
611
+ throw new z.ZodError([{ code: "custom", message: getMessage("min", { min: minFormatted }), path: [] }])
612
+ }
613
+ }
614
+
615
+ if (max) {
616
+ const maxParsed = typeof max === "string" ? parseDateTimeValue(max, format, timezone) : dayjs(max)
617
+ if (maxParsed && parsed.isAfter(maxParsed)) {
618
+ const maxFormatted = typeof max === "string" ? max : maxParsed.format(format)
619
+ throw new z.ZodError([{ code: "custom", message: getMessage("max", { max: maxFormatted }), path: [] }])
620
+ }
621
+ }
622
+
623
+ // Time-based validations
624
+ const now = timezone ? dayjs().tz(timezone) : dayjs()
625
+
626
+ if (mustBePast && !parsed.isBefore(now)) {
627
+ throw new z.ZodError([{ code: "custom", message: getMessage("past"), path: [] }])
628
+ }
629
+
630
+ if (mustBeFuture && !parsed.isAfter(now)) {
631
+ throw new z.ZodError([{ code: "custom", message: getMessage("future"), path: [] }])
632
+ }
633
+
634
+ if (mustBeToday && !parsed.isSame(now, "day")) {
635
+ throw new z.ZodError([{ code: "custom", message: getMessage("today"), path: [] }])
636
+ }
637
+
638
+ if (mustNotBeToday && parsed.isSame(now, "day")) {
639
+ throw new z.ZodError([{ code: "custom", message: getMessage("notToday"), path: [] }])
640
+ }
641
+
642
+ // Weekday validations
643
+ const dayOfWeek = parsed.day() // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
644
+
645
+ if (weekdaysOnly && (dayOfWeek === 0 || dayOfWeek === 6)) {
646
+ throw new z.ZodError([{ code: "custom", message: getMessage("weekday"), path: [] }])
647
+ }
648
+
649
+ if (weekendsOnly && dayOfWeek !== 0 && dayOfWeek !== 6) {
650
+ throw new z.ZodError([{ code: "custom", message: getMessage("weekend"), path: [] }])
651
+ }
652
+
653
+ return true
654
+ })
655
+
656
+ return schema as unknown as DateTimeSchema<IsRequired>
657
+ }
658
+
659
+ /**
660
+ * Utility functions and constants exported for external use
661
+ *
662
+ * @description
663
+ * These utilities can be used independently for datetime parsing, validation, and normalization
664
+ * without creating a full Zod schema. Useful for custom validation logic or preprocessing.
665
+ *
666
+ * @example
667
+ * ```typescript
668
+ * import { validateDateTimeFormat, parseDateTimeValue, DATETIME_PATTERNS } from './datetime'
669
+ *
670
+ * // Check if a string matches a format
671
+ * const isValid = validateDateTimeFormat("2024-03-15 14:30", "YYYY-MM-DD HH:mm")
672
+ *
673
+ * // Parse to dayjs object
674
+ * const parsed = parseDateTimeValue("2024-03-15 14:30", "YYYY-MM-DD HH:mm")
675
+ *
676
+ * // Access regex patterns
677
+ * const pattern = DATETIME_PATTERNS["YYYY-MM-DD HH:mm"]
678
+ * ```
679
+ */
680
+ export { validateDateTimeFormat, parseDateTimeValue, normalizeDateTimeValue, DATETIME_PATTERNS }