@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,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 }
|