@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.
- package/.claude/settings.local.json +9 -1
- package/README.md +465 -97
- package/dist/index.cjs +1690 -179
- package/dist/index.d.cts +2791 -28
- package/dist/index.d.ts +2791 -28
- package/dist/index.js +1672 -178
- package/package.json +2 -1
- package/src/i18n/locales/en.json +62 -0
- package/src/i18n/locales/zh-TW.json +62 -0
- package/src/index.ts +4 -0
- package/src/validators/common/boolean.ts +101 -4
- package/src/validators/common/date.ts +141 -6
- package/src/validators/common/datetime.ts +680 -0
- package/src/validators/common/email.ts +120 -4
- package/src/validators/common/file.ts +391 -0
- package/src/validators/common/id.ts +230 -18
- package/src/validators/common/number.ts +132 -4
- package/src/validators/common/password.ts +187 -8
- package/src/validators/common/text.ts +130 -6
- package/src/validators/common/time.ts +607 -0
- package/src/validators/common/url.ts +153 -6
- package/src/validators/taiwan/business-id.ts +138 -9
- package/src/validators/taiwan/fax.ts +164 -10
- package/src/validators/taiwan/mobile.ts +151 -10
- package/src/validators/taiwan/national-id.ts +233 -17
- package/src/validators/taiwan/postal-code.ts +1048 -0
- package/src/validators/taiwan/tel.ts +167 -10
- package/tests/common/boolean.test.ts +38 -38
- package/tests/common/date.test.ts +65 -65
- package/tests/common/datetime.test.ts +675 -0
- package/tests/common/email.test.ts +24 -28
- package/tests/common/file.test.ts +475 -0
- package/tests/common/id.test.ts +80 -113
- package/tests/common/number.test.ts +24 -25
- package/tests/common/password.test.ts +28 -35
- package/tests/common/text.test.ts +36 -37
- package/tests/common/time.test.ts +510 -0
- package/tests/common/url.test.ts +67 -67
- package/tests/taiwan/business-id.test.ts +22 -22
- package/tests/taiwan/fax.test.ts +33 -42
- package/tests/taiwan/mobile.test.ts +32 -41
- package/tests/taiwan/national-id.test.ts +31 -31
- package/tests/taiwan/postal-code.test.ts +751 -0
- package/tests/taiwan/tel.test.ts +33 -42
- package/debug.js +0 -21
- 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 }
|