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