@hy_ong/zod-kit 0.0.4 → 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.
Files changed (59) hide show
  1. package/.claude/settings.local.json +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +465 -97
  4. package/debug.js +21 -0
  5. package/debug.ts +16 -0
  6. package/dist/index.cjs +3127 -146
  7. package/dist/index.d.cts +3021 -25
  8. package/dist/index.d.ts +3021 -25
  9. package/dist/index.js +3081 -144
  10. package/eslint.config.mts +8 -0
  11. package/package.json +10 -9
  12. package/src/config.ts +1 -1
  13. package/src/i18n/locales/en.json +161 -25
  14. package/src/i18n/locales/zh-TW.json +165 -26
  15. package/src/index.ts +17 -7
  16. package/src/validators/common/boolean.ts +191 -0
  17. package/src/validators/common/date.ts +299 -0
  18. package/src/validators/common/datetime.ts +673 -0
  19. package/src/validators/common/email.ts +313 -0
  20. package/src/validators/common/file.ts +384 -0
  21. package/src/validators/common/id.ts +471 -0
  22. package/src/validators/common/number.ts +319 -0
  23. package/src/validators/common/password.ts +386 -0
  24. package/src/validators/common/text.ts +271 -0
  25. package/src/validators/common/time.ts +600 -0
  26. package/src/validators/common/url.ts +347 -0
  27. package/src/validators/taiwan/business-id.ts +262 -0
  28. package/src/validators/taiwan/fax.ts +327 -0
  29. package/src/validators/taiwan/mobile.ts +242 -0
  30. package/src/validators/taiwan/national-id.ts +425 -0
  31. package/src/validators/taiwan/postal-code.ts +1049 -0
  32. package/src/validators/taiwan/tel.ts +330 -0
  33. package/tests/common/boolean.test.ts +340 -92
  34. package/tests/common/date.test.ts +458 -0
  35. package/tests/common/datetime.test.ts +693 -0
  36. package/tests/common/email.test.ts +232 -60
  37. package/tests/common/file.test.ts +479 -0
  38. package/tests/common/id.test.ts +535 -0
  39. package/tests/common/number.test.ts +230 -60
  40. package/tests/common/password.test.ts +271 -44
  41. package/tests/common/text.test.ts +210 -13
  42. package/tests/common/time.test.ts +528 -0
  43. package/tests/common/url.test.ts +492 -67
  44. package/tests/taiwan/business-id.test.ts +240 -0
  45. package/tests/taiwan/fax.test.ts +463 -0
  46. package/tests/taiwan/mobile.test.ts +373 -0
  47. package/tests/taiwan/national-id.test.ts +435 -0
  48. package/tests/taiwan/postal-code.test.ts +705 -0
  49. package/tests/taiwan/tel.test.ts +467 -0
  50. package/eslint.config.mjs +0 -10
  51. package/src/common/boolean.ts +0 -36
  52. package/src/common/date.ts +0 -43
  53. package/src/common/email.ts +0 -44
  54. package/src/common/integer.ts +0 -46
  55. package/src/common/number.ts +0 -37
  56. package/src/common/password.ts +0 -33
  57. package/src/common/text.ts +0 -34
  58. package/src/common/url.ts +0 -37
  59. package/tests/common/integer.test.ts +0 -90
@@ -0,0 +1,600 @@
1
+ /**
2
+ * @fileoverview Time validator for Zod Kit
3
+ *
4
+ * Provides comprehensive time validation with support for multiple time formats,
5
+ * hour/minute constraints, and advanced time-based validation options.
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
+
15
+ /**
16
+ * Type definition for time validation error messages
17
+ *
18
+ * @interface TimeMessages
19
+ * @property {string} [required] - Message when field is required but empty
20
+ * @property {string} [invalid] - Message when time format is invalid
21
+ * @property {string} [format] - Message when time doesn't match expected format
22
+ * @property {string} [min] - Message when time is before minimum allowed
23
+ * @property {string} [max] - Message when time is after maximum allowed
24
+ * @property {string} [hour] - Message when hour is outside allowed range
25
+ * @property {string} [minute] - Message when minute doesn't match step requirement
26
+ * @property {string} [second] - Message when second doesn't match step requirement
27
+ * @property {string} [includes] - Message when time doesn't contain required string
28
+ * @property {string} [excludes] - Message when time contains forbidden string
29
+ * @property {string} [customRegex] - Message when custom regex validation fails
30
+ * @property {string} [notInWhitelist] - Message when value is not in whitelist
31
+ */
32
+ export type TimeMessages = {
33
+ required?: string
34
+ invalid?: string
35
+ format?: string
36
+ min?: string
37
+ max?: string
38
+ hour?: string
39
+ minute?: string
40
+ second?: string
41
+ includes?: string
42
+ excludes?: string
43
+ customRegex?: string
44
+ notInWhitelist?: string
45
+ }
46
+
47
+ /**
48
+ * Supported time formats for validation
49
+ *
50
+ * @typedef {string} TimeFormat
51
+ *
52
+ * Available formats:
53
+ * - HH:mm: 24-hour format with leading zeros (14:30, 09:30)
54
+ * - HH:mm:ss: 24-hour format with seconds (14:30:45, 09:30:15)
55
+ * - hh:mm A: 12-hour format with AM/PM (02:30 PM, 09:30 AM)
56
+ * - hh:mm:ss A: 12-hour format with seconds and AM/PM (02:30:45 PM)
57
+ * - H:mm: 24-hour format without leading zeros (14:30, 9:30)
58
+ * - h:mm A: 12-hour format without leading zeros (2:30 PM, 9:30 AM)
59
+ */
60
+ export type TimeFormat =
61
+ | "HH:mm" // 24-hour format (14:30)
62
+ | "HH:mm:ss" // 24-hour with seconds (14:30:45)
63
+ | "hh:mm A" // 12-hour format (02:30 PM)
64
+ | "hh:mm:ss A" // 12-hour with seconds (02:30:45 PM)
65
+ | "H:mm" // 24-hour no leading zero (14:30, 9:30)
66
+ | "h:mm A" // 12-hour no leading zero (2:30 PM, 9:30 AM)
67
+
68
+ /**
69
+ * Configuration options for time validation
70
+ *
71
+ * @template IsRequired - Whether the field is required (affects return type)
72
+ *
73
+ * @interface TimeOptions
74
+ * @property {IsRequired} [required=true] - Whether the field is required
75
+ * @property {TimeFormat} [format="HH:mm"] - Expected time format
76
+ * @property {string} [min] - Minimum allowed time (e.g., "09:00")
77
+ * @property {string} [max] - Maximum allowed time (e.g., "17:00")
78
+ * @property {number} [minHour] - Minimum allowed hour (0-23)
79
+ * @property {number} [maxHour] - Maximum allowed hour (0-23)
80
+ * @property {number[]} [allowedHours] - Specific hours that are allowed
81
+ * @property {number} [minuteStep] - Required minute intervals (e.g., 15 for :00, :15, :30, :45)
82
+ * @property {number} [secondStep] - Required second intervals
83
+ * @property {string} [includes] - String that must be included in the time
84
+ * @property {string | string[]} [excludes] - String(s) that must not be included
85
+ * @property {RegExp} [regex] - Custom regex for validation (overrides format validation)
86
+ * @property {"trim" | "trimStart" | "trimEnd" | "none"} [trimMode="trim"] - Whitespace handling
87
+ * @property {"upper" | "lower" | "none"} [casing="none"] - Case transformation
88
+ * @property {string[]} [whitelist] - Specific time strings that are always allowed
89
+ * @property {boolean} [whitelistOnly=false] - If true, only values in whitelist are allowed
90
+ * @property {Function} [transform] - Custom transformation function applied before validation
91
+ * @property {string | null} [defaultValue] - Default value when input is empty
92
+ * @property {Record<Locale, TimeMessages>} [i18n] - Custom error messages for different locales
93
+ */
94
+ export type TimeOptions<IsRequired extends boolean = true> = {
95
+ required?: IsRequired
96
+ format?: TimeFormat
97
+ min?: string // Minimum time (e.g., "09:00")
98
+ max?: string // Maximum time (e.g., "17:00")
99
+ minHour?: number // Minimum hour (0-23)
100
+ maxHour?: number // Maximum hour (0-23)
101
+ allowedHours?: number[] // Specific hours allowed
102
+ minuteStep?: number // Minute intervals (e.g., 15 for :00, :15, :30, :45)
103
+ secondStep?: number // Second intervals
104
+ includes?: string // Must include specific substring
105
+ excludes?: string | string[] // Must not contain specific substring(s)
106
+ regex?: RegExp // Custom regex validation (overrides format)
107
+ trimMode?: "trim" | "trimStart" | "trimEnd" | "none" // Whitespace handling
108
+ casing?: "upper" | "lower" | "none" // Case transformation
109
+ whitelist?: string[] // Allow specific time strings
110
+ whitelistOnly?: boolean // If true, only allow values in whitelist (default: false)
111
+ transform?: (value: string) => string
112
+ defaultValue?: IsRequired extends true ? string : string | null
113
+ i18n?: Record<Locale, TimeMessages>
114
+ }
115
+
116
+ /**
117
+ * Type alias for time validation schema based on required flag
118
+ *
119
+ * @template IsRequired - Whether the field is required
120
+ * @typedef TimeSchema
121
+ * @description Returns ZodString if required, ZodNullable<ZodString> if optional
122
+ */
123
+ export type TimeSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
124
+
125
+ /**
126
+ * Regular expression patterns for time format validation
127
+ *
128
+ * @constant {Record<TimeFormat, RegExp>} TIME_PATTERNS
129
+ * @description Maps each supported time format to its corresponding regex pattern
130
+ */
131
+ const TIME_PATTERNS: Record<TimeFormat, RegExp> = {
132
+ "HH:mm": /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/,
133
+ "HH:mm:ss": /^([01]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/,
134
+ "hh:mm A": /^(0?[1-9]|1[0-2]):[0-5][0-9]\s?(AM|PM)$/i,
135
+ "hh:mm:ss A": /^(0?[1-9]|1[0-2]):[0-5][0-9]:[0-5][0-9]\s?(AM|PM)$/i,
136
+ "H:mm": /^([0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/,
137
+ "h:mm A": /^([1-9]|1[0-2]):[0-5][0-9]\s?(AM|PM)$/i,
138
+ }
139
+
140
+ /**
141
+ * Parses a time string to minutes since midnight for comparison
142
+ *
143
+ * @param {string} timeStr - The time string to parse
144
+ * @param {TimeFormat} format - The expected time format
145
+ * @returns {number | null} Minutes since midnight (0-1439) or null if parsing fails
146
+ *
147
+ * @description
148
+ * Converts time strings to minutes since midnight for easy comparison and validation.
149
+ * Handles both 12-hour and 24-hour formats with proper AM/PM conversion.
150
+ *
151
+ * @example
152
+ * ```typescript
153
+ * parseTimeToMinutes("14:30", "HH:mm") // 870 (14*60 + 30)
154
+ * parseTimeToMinutes("2:30 PM", "h:mm A") // 870 (14*60 + 30)
155
+ * parseTimeToMinutes("12:00 AM", "hh:mm A") // 0 (midnight)
156
+ * parseTimeToMinutes("12:00 PM", "hh:mm A") // 720 (noon)
157
+ * ```
158
+ */
159
+ const parseTimeToMinutes = (timeStr: string, format: TimeFormat): number | null => {
160
+ const cleanTime = timeStr.trim()
161
+
162
+ try {
163
+ if (format.includes("A")) {
164
+ // 12-hour format
165
+ const match = cleanTime.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s?(AM|PM)$/i)
166
+ if (!match) return null
167
+
168
+ const [, hourStr, minuteStr, , period] = match
169
+ let hour = parseInt(hourStr, 10)
170
+ const minute = parseInt(minuteStr, 10)
171
+
172
+ // Validate ranges for 12-hour format
173
+ if (hour < 1 || hour > 12 || minute < 0 || minute > 59) {
174
+ return null
175
+ }
176
+
177
+ if (period.toUpperCase() === "PM" && hour !== 12) {
178
+ hour += 12
179
+ } else if (period.toUpperCase() === "AM" && hour === 12) {
180
+ hour = 0
181
+ }
182
+
183
+ return hour * 60 + minute
184
+ } else {
185
+ // 24-hour format
186
+ const match = cleanTime.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
187
+ if (!match) return null
188
+
189
+ const [, hourStr, minuteStr] = match
190
+ const hour = parseInt(hourStr, 10)
191
+ const minute = parseInt(minuteStr, 10)
192
+
193
+ // Validate ranges for 24-hour format
194
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
195
+ return null
196
+ }
197
+
198
+ return hour * 60 + minute
199
+ }
200
+ } catch {
201
+ return null
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Validates if a time string matches the specified format pattern
207
+ *
208
+ * @param {string} value - The time string to validate
209
+ * @param {TimeFormat} format - The expected time format
210
+ * @returns {boolean} True if the time matches the format pattern
211
+ *
212
+ * @description
213
+ * Performs regex pattern matching to validate time format.
214
+ * Does not validate actual time values (e.g., 25:00 would pass pattern but fail logic).
215
+ *
216
+ * @example
217
+ * ```typescript
218
+ * validateTimeFormat("14:30", "HH:mm") // true
219
+ * validateTimeFormat("2:30 PM", "h:mm A") // true
220
+ * validateTimeFormat("25:00", "HH:mm") // true (pattern matches)
221
+ * validateTimeFormat("14:30", "h:mm A") // false (wrong format)
222
+ * ```
223
+ */
224
+ const validateTimeFormat = (value: string, format: TimeFormat): boolean => {
225
+ const pattern = TIME_PATTERNS[format]
226
+ return pattern.test(value.trim())
227
+ }
228
+
229
+ /**
230
+ * Normalizes time to 24-hour format for internal processing
231
+ *
232
+ * @param {string} timeStr - The time string to normalize
233
+ * @param {TimeFormat} format - The current time format
234
+ * @returns {string | null} Normalized time string or null if parsing fails
235
+ *
236
+ * @description
237
+ * Converts time strings to a standardized 24-hour format for consistent processing.
238
+ * Handles AM/PM conversion and leading zero normalization.
239
+ *
240
+ * @example
241
+ * ```typescript
242
+ * normalizeTime("2:30 PM", "h:mm A") // "14:30"
243
+ * normalizeTime("12:00 AM", "hh:mm A") // "00:00"
244
+ * normalizeTime("9:30", "H:mm") // "09:30"
245
+ * normalizeTime("14:30:45", "HH:mm:ss") // "14:30:45"
246
+ * ```
247
+ */
248
+ const normalizeTime = (timeStr: string, format: TimeFormat): string | null => {
249
+ const cleanTime = timeStr.trim()
250
+
251
+ if (format.includes("A")) {
252
+ // Convert 12-hour to 24-hour
253
+ const match = cleanTime.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s?(AM|PM)$/i)
254
+ if (!match) return null
255
+
256
+ const [, hourStr, minuteStr, secondStr = "00", period] = match
257
+ let hour = parseInt(hourStr, 10)
258
+ const minute = parseInt(minuteStr, 10)
259
+ const second = parseInt(secondStr, 10)
260
+
261
+ if (period.toUpperCase() === "PM" && hour !== 12) {
262
+ hour += 12
263
+ } else if (period.toUpperCase() === "AM" && hour === 12) {
264
+ hour = 0
265
+ }
266
+
267
+ return format.includes("ss")
268
+ ? `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:${second.toString().padStart(2, "0")}`
269
+ : `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`
270
+ }
271
+
272
+ // Already 24-hour format, just normalize
273
+ if (format === "H:mm") {
274
+ const match = cleanTime.match(/^(\d{1,2}):(\d{2})$/)
275
+ if (!match) return null
276
+ const [, hourStr, minuteStr] = match
277
+ return `${hourStr.padStart(2, "0")}:${minuteStr}`
278
+ }
279
+
280
+ return cleanTime
281
+ }
282
+
283
+ /**
284
+ * Creates a Zod schema for time validation with comprehensive options
285
+ *
286
+ * @template IsRequired - Whether the field is required (affects return type)
287
+ * @param {TimeOptions<IsRequired>} [options] - Configuration options for time validation
288
+ * @returns {TimeSchema<IsRequired>} Zod schema for time validation
289
+ *
290
+ * @description
291
+ * Creates a comprehensive time validator that supports multiple time formats,
292
+ * hour/minute constraints, step validation, and extensive customization options.
293
+ *
294
+ * Features:
295
+ * - Multiple time formats (24-hour, 12-hour, with/without seconds)
296
+ * - Time range validation (min/max)
297
+ * - Hour and minute constraints
298
+ * - Step validation (e.g., 15-minute intervals)
299
+ * - Whitelist/blacklist support
300
+ * - Custom regex patterns
301
+ * - String transformation and case handling
302
+ * - Comprehensive internationalization
303
+ *
304
+ * @example
305
+ * ```typescript
306
+ * // Basic time validation (24-hour format)
307
+ * const basicSchema = time()
308
+ * basicSchema.parse("14:30") // ✓ Valid
309
+ * basicSchema.parse("2:30 PM") // ✗ Invalid (wrong format)
310
+ *
311
+ * // 12-hour format with AM/PM
312
+ * const ampmSchema = time({ format: "hh:mm A" })
313
+ * ampmSchema.parse("02:30 PM") // ✓ Valid
314
+ * ampmSchema.parse("14:30") // ✗ Invalid (wrong format)
315
+ *
316
+ * // Business hours validation
317
+ * const businessHours = time({
318
+ * format: "HH:mm",
319
+ * minHour: 9,
320
+ * maxHour: 17,
321
+ * minuteStep: 15 // Only :00, :15, :30, :45
322
+ * })
323
+ * businessHours.parse("09:15") // ✓ Valid
324
+ * businessHours.parse("18:00") // ✗ Invalid (after maxHour)
325
+ * businessHours.parse("09:05") // ✗ Invalid (not 15-minute step)
326
+ *
327
+ * // Time range validation
328
+ * const timeRangeSchema = time({
329
+ * min: "09:00",
330
+ * max: "17:00"
331
+ * })
332
+ * timeRangeSchema.parse("12:30") // ✓ Valid
333
+ * timeRangeSchema.parse("08:00") // ✗ Invalid (before min)
334
+ *
335
+ * // Allowed hours only
336
+ * const specificHours = time({
337
+ * allowedHours: [9, 12, 15, 18]
338
+ * })
339
+ * specificHours.parse("12:30") // ✓ Valid
340
+ * specificHours.parse("11:30") // ✗ Invalid (hour not allowed)
341
+ *
342
+ * // Whitelist specific times
343
+ * const whitelistSchema = time({
344
+ * whitelist: ["09:00", "12:00", "17:00"],
345
+ * whitelistOnly: true
346
+ * })
347
+ * whitelistSchema.parse("12:00") // ✓ Valid (in whitelist)
348
+ * whitelistSchema.parse("13:00") // ✗ Invalid (not in whitelist)
349
+ *
350
+ * // Optional with default
351
+ * const optionalSchema = time({
352
+ * required: false,
353
+ * defaultValue: "09:00"
354
+ * })
355
+ * optionalSchema.parse("") // ✓ Valid (returns "09:00")
356
+ * ```
357
+ *
358
+ * @throws {z.ZodError} When validation fails with specific error messages
359
+ * @see {@link TimeOptions} for all available configuration options
360
+ * @see {@link TimeFormat} for supported time formats
361
+ */
362
+ export function time<IsRequired extends boolean = true>(options?: TimeOptions<IsRequired>): TimeSchema<IsRequired> {
363
+ const {
364
+ required = true,
365
+ format = "HH:mm",
366
+ min,
367
+ max,
368
+ minHour,
369
+ maxHour,
370
+ allowedHours,
371
+ minuteStep,
372
+ secondStep,
373
+ includes,
374
+ excludes,
375
+ regex,
376
+ trimMode = "trim",
377
+ casing = "none",
378
+ whitelist,
379
+ whitelistOnly = false,
380
+ transform,
381
+ defaultValue,
382
+ i18n,
383
+ } = options ?? {}
384
+
385
+ // Set appropriate default value based on required flag
386
+ const actualDefaultValue = defaultValue ?? (required ? "" : null)
387
+
388
+ // Helper function to get custom message or fallback to default i18n
389
+ const getMessage = (key: keyof TimeMessages, params?: Record<string, any>) => {
390
+ if (i18n) {
391
+ const currentLocale = getLocale()
392
+ const customMessages = i18n[currentLocale]
393
+ if (customMessages && customMessages[key]) {
394
+ const template = customMessages[key]!
395
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
396
+ }
397
+ }
398
+ return t(`common.time.${key}`, params)
399
+ }
400
+
401
+ // Preprocessing function
402
+ const preprocessFn = (val: unknown) => {
403
+ if (val === null || val === undefined) {
404
+ return actualDefaultValue
405
+ }
406
+
407
+ let processed = String(val)
408
+
409
+ // Apply trim mode
410
+ switch (trimMode) {
411
+ case "trim":
412
+ processed = processed.trim()
413
+ break
414
+ case "trimStart":
415
+ processed = processed.trimStart()
416
+ break
417
+ case "trimEnd":
418
+ processed = processed.trimEnd()
419
+ break
420
+ case "none":
421
+ // No trimming
422
+ break
423
+ }
424
+
425
+ // If after trimming we have an empty string
426
+ if (processed === "") {
427
+ // If empty string is in whitelist, return it as is
428
+ if (whitelist && whitelist.includes("")) {
429
+ return ""
430
+ }
431
+ // If the field is optional and empty string not in whitelist, return default value
432
+ if (!required) {
433
+ return actualDefaultValue
434
+ }
435
+ // If a field is required, return the default value (will be validated later)
436
+ return actualDefaultValue
437
+ }
438
+
439
+ // Apply case transformation
440
+ switch (casing) {
441
+ case "upper":
442
+ processed = processed.toUpperCase()
443
+ break
444
+ case "lower":
445
+ processed = processed.toLowerCase()
446
+ break
447
+ case "none":
448
+ // No case transformation
449
+ break
450
+ }
451
+
452
+ if (transform) {
453
+ processed = transform(processed)
454
+ }
455
+
456
+ return processed
457
+ }
458
+
459
+ const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
460
+
461
+ const schema = baseSchema.refine((val) => {
462
+ if (val === null) return true
463
+
464
+ // Required check
465
+ if (required && (val === "" || val === "null" || val === "undefined")) {
466
+ throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
467
+ }
468
+
469
+ if (val === null) return true
470
+ if (!required && val === "") return true
471
+
472
+ // Whitelist check
473
+ if (whitelist && whitelist.length > 0) {
474
+ if (whitelist.includes(val)) {
475
+ return true
476
+ }
477
+ // If whitelistOnly is true, reject values not in whitelist
478
+ if (whitelistOnly) {
479
+ throw new z.ZodError([{ code: "custom", message: getMessage("notInWhitelist"), path: [] }])
480
+ }
481
+ // Otherwise, continue with normal validation
482
+ }
483
+
484
+ // Custom regex validation (overrides format validation)
485
+ if (regex) {
486
+ if (!regex.test(val)) {
487
+ throw new z.ZodError([{ code: "custom", message: getMessage("customRegex"), path: [] }])
488
+ }
489
+ } else {
490
+ // Time format validation (only if no regex is provided)
491
+ if (!validateTimeFormat(val, format)) {
492
+ throw new z.ZodError([{ code: "custom", message: getMessage("format", { format }), path: [] }])
493
+ }
494
+ }
495
+
496
+ // String content checks
497
+ if (includes && !val.includes(includes)) {
498
+ throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
499
+ }
500
+
501
+ if (excludes) {
502
+ const excludeList = Array.isArray(excludes) ? excludes : [excludes]
503
+ for (const exclude of excludeList) {
504
+ if (val.includes(exclude)) {
505
+ throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
506
+ }
507
+ }
508
+ }
509
+
510
+ // Skip time parsing and range validation if using custom regex
511
+ if (regex) {
512
+ return true
513
+ }
514
+
515
+ // Parse time for range validation
516
+ const timeMinutes = parseTimeToMinutes(val, format)
517
+ if (timeMinutes === null) {
518
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
519
+ }
520
+
521
+ // Hour validation
522
+ const hour = Math.floor(timeMinutes / 60)
523
+ if (minHour !== undefined && hour < minHour) {
524
+ throw new z.ZodError([{ code: "custom", message: getMessage("hour", { minHour, maxHour: maxHour ?? 23 }), path: [] }])
525
+ }
526
+ if (maxHour !== undefined && hour > maxHour) {
527
+ throw new z.ZodError([{ code: "custom", message: getMessage("hour", { minHour: minHour ?? 0, maxHour }), path: [] }])
528
+ }
529
+
530
+ // Allowed hours check
531
+ if (allowedHours && allowedHours.length > 0) {
532
+ if (!allowedHours.includes(hour)) {
533
+ throw new z.ZodError([{ code: "custom", message: getMessage("hour", { allowedHours: allowedHours.join(", ") }), path: [] }])
534
+ }
535
+ }
536
+
537
+ // Minute step validation
538
+ const minute = timeMinutes % 60
539
+ if (minuteStep !== undefined && minute % minuteStep !== 0) {
540
+ throw new z.ZodError([{ code: "custom", message: getMessage("minute", { minuteStep }), path: [] }])
541
+ }
542
+
543
+ // Second step validation (only for formats with seconds)
544
+ if (secondStep !== undefined && format.includes("ss")) {
545
+ // Parse seconds from the original value
546
+ const secondMatch = val.match(/:(\d{2})$/)
547
+ if (secondMatch) {
548
+ const seconds = parseInt(secondMatch[1], 10)
549
+ if (seconds % secondStep !== 0) {
550
+ throw new z.ZodError([{ code: "custom", message: getMessage("second", { secondStep }), path: [] }])
551
+ }
552
+ }
553
+ }
554
+
555
+ // Time range validation (min/max)
556
+ if (min) {
557
+ const minMinutes = parseTimeToMinutes(min, format)
558
+ if (minMinutes !== null && timeMinutes < minMinutes) {
559
+ throw new z.ZodError([{ code: "custom", message: getMessage("min", { min }), path: [] }])
560
+ }
561
+ }
562
+
563
+ if (max) {
564
+ const maxMinutes = parseTimeToMinutes(max, format)
565
+ if (maxMinutes !== null && timeMinutes > maxMinutes) {
566
+ throw new z.ZodError([{ code: "custom", message: getMessage("max", { max }), path: [] }])
567
+ }
568
+ }
569
+
570
+ return true
571
+ })
572
+
573
+ return schema as unknown as TimeSchema<IsRequired>
574
+ }
575
+
576
+ /**
577
+ * Utility functions and constants exported for external use
578
+ *
579
+ * @description
580
+ * These utilities can be used independently for time parsing, validation, and normalization
581
+ * without creating a full Zod schema. Useful for custom validation logic or preprocessing.
582
+ *
583
+ * @example
584
+ * ```typescript
585
+ * import { validateTimeFormat, parseTimeToMinutes, normalizeTime, TIME_PATTERNS } from './time'
586
+ *
587
+ * // Check if a string matches a format
588
+ * const isValid = validateTimeFormat("14:30", "HH:mm")
589
+ *
590
+ * // Convert time to minutes for comparison
591
+ * const minutes = parseTimeToMinutes("2:30 PM", "h:mm A") // 870
592
+ *
593
+ * // Normalize to 24-hour format
594
+ * const normalized = normalizeTime("2:30 PM", "h:mm A") // "14:30"
595
+ *
596
+ * // Access regex patterns
597
+ * const pattern = TIME_PATTERNS["HH:mm"]
598
+ * ```
599
+ */
600
+ export { validateTimeFormat, parseTimeToMinutes, normalizeTime, TIME_PATTERNS }