@hy_ong/zod-kit 0.0.2 → 0.0.5
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 +23 -0
- package/LICENSE +21 -0
- package/README.md +7 -7
- package/debug.js +21 -0
- package/debug.ts +16 -0
- package/dist/index.cjs +1663 -189
- package/dist/index.d.cts +324 -32
- package/dist/index.d.ts +324 -32
- package/dist/index.js +1634 -187
- package/eslint.config.mts +8 -0
- package/package.json +10 -9
- package/src/config.ts +1 -1
- package/src/i18n/locales/en.json +123 -49
- package/src/i18n/locales/zh-TW.json +123 -46
- package/src/index.ts +13 -7
- package/src/validators/common/boolean.ts +97 -0
- package/src/validators/common/date.ts +171 -0
- package/src/validators/common/email.ts +200 -0
- package/src/validators/common/id.ts +259 -0
- package/src/validators/common/number.ts +194 -0
- package/src/validators/common/password.ts +214 -0
- package/src/validators/common/text.ts +151 -0
- package/src/validators/common/url.ts +207 -0
- package/src/validators/taiwan/business-id.ts +140 -0
- package/src/validators/taiwan/fax.ts +182 -0
- package/src/validators/taiwan/mobile.ts +110 -0
- package/src/validators/taiwan/national-id.ts +208 -0
- package/src/validators/taiwan/tel.ts +182 -0
- package/tests/common/boolean.test.ts +340 -92
- package/tests/common/date.test.ts +458 -0
- package/tests/common/email.test.ts +232 -60
- package/tests/common/id.test.ts +535 -0
- package/tests/common/number.test.ts +230 -60
- package/tests/common/password.test.ts +281 -54
- package/tests/common/text.test.ts +227 -30
- package/tests/common/url.test.ts +492 -67
- package/tests/taiwan/business-id.test.ts +240 -0
- package/tests/taiwan/fax.test.ts +463 -0
- package/tests/taiwan/mobile.test.ts +373 -0
- package/tests/taiwan/national-id.test.ts +435 -0
- package/tests/taiwan/tel.test.ts +467 -0
- package/eslint.config.mjs +0 -10
- package/src/common/boolean.ts +0 -37
- package/src/common/date.ts +0 -44
- package/src/common/email.ts +0 -45
- package/src/common/integer.ts +0 -47
- package/src/common/number.ts +0 -38
- package/src/common/password.ts +0 -34
- package/src/common/text.ts +0 -35
- package/src/common/url.ts +0 -38
- package/tests/common/integer.test.ts +0 -90
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
import dayjs from "dayjs"
|
|
5
|
+
import customParseFormat from "dayjs/plugin/customParseFormat"
|
|
6
|
+
import isSameOrAfter from "dayjs/plugin/isSameOrAfter"
|
|
7
|
+
import isSameOrBefore from "dayjs/plugin/isSameOrBefore"
|
|
8
|
+
import isToday from "dayjs/plugin/isToday"
|
|
9
|
+
import weekday from "dayjs/plugin/weekday"
|
|
10
|
+
|
|
11
|
+
dayjs.extend(isSameOrAfter)
|
|
12
|
+
dayjs.extend(isSameOrBefore)
|
|
13
|
+
dayjs.extend(customParseFormat)
|
|
14
|
+
dayjs.extend(isToday)
|
|
15
|
+
dayjs.extend(weekday)
|
|
16
|
+
|
|
17
|
+
export type DateMessages = {
|
|
18
|
+
required?: string
|
|
19
|
+
invalid?: string
|
|
20
|
+
format?: string
|
|
21
|
+
min?: string
|
|
22
|
+
max?: string
|
|
23
|
+
includes?: string
|
|
24
|
+
excludes?: string
|
|
25
|
+
past?: string
|
|
26
|
+
future?: string
|
|
27
|
+
today?: string
|
|
28
|
+
notToday?: string
|
|
29
|
+
weekday?: string
|
|
30
|
+
notWeekday?: string
|
|
31
|
+
weekend?: string
|
|
32
|
+
notWeekend?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type DateOptions<IsRequired extends boolean = true> = {
|
|
36
|
+
required?: IsRequired
|
|
37
|
+
min?: string
|
|
38
|
+
max?: string
|
|
39
|
+
format?: string
|
|
40
|
+
includes?: string
|
|
41
|
+
excludes?: string | string[]
|
|
42
|
+
mustBePast?: boolean
|
|
43
|
+
mustBeFuture?: boolean
|
|
44
|
+
mustBeToday?: boolean
|
|
45
|
+
mustNotBeToday?: boolean
|
|
46
|
+
weekdaysOnly?: boolean
|
|
47
|
+
weekendsOnly?: boolean
|
|
48
|
+
transform?: (value: string) => string
|
|
49
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
50
|
+
i18n?: Record<Locale, DateMessages>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type DateSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
54
|
+
|
|
55
|
+
export function date<IsRequired extends boolean = true>(options?: DateOptions<IsRequired>): DateSchema<IsRequired> {
|
|
56
|
+
const {
|
|
57
|
+
required = true,
|
|
58
|
+
min,
|
|
59
|
+
max,
|
|
60
|
+
format = "YYYY-MM-DD",
|
|
61
|
+
includes,
|
|
62
|
+
excludes,
|
|
63
|
+
mustBePast,
|
|
64
|
+
mustBeFuture,
|
|
65
|
+
mustBeToday,
|
|
66
|
+
mustNotBeToday,
|
|
67
|
+
weekdaysOnly,
|
|
68
|
+
weekendsOnly,
|
|
69
|
+
transform,
|
|
70
|
+
defaultValue = null,
|
|
71
|
+
i18n,
|
|
72
|
+
} = options ?? {}
|
|
73
|
+
|
|
74
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
75
|
+
|
|
76
|
+
// Helper function to get custom message or fallback to default i18n
|
|
77
|
+
const getMessage = (key: keyof DateMessages, params?: Record<string, any>) => {
|
|
78
|
+
if (i18n) {
|
|
79
|
+
const currentLocale = getLocale()
|
|
80
|
+
const customMessages = i18n[currentLocale]
|
|
81
|
+
if (customMessages && customMessages[key]) {
|
|
82
|
+
const template = customMessages[key]!
|
|
83
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return t(`common.date.${key}`, params)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Preprocessing function with transformations
|
|
90
|
+
const preprocessFn = (val: unknown) => {
|
|
91
|
+
if (val === "" || val === null || val === undefined) {
|
|
92
|
+
return actualDefaultValue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let processed = String(val).trim()
|
|
96
|
+
|
|
97
|
+
if (transform) {
|
|
98
|
+
processed = transform(processed)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return processed
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
105
|
+
|
|
106
|
+
const schema = baseSchema.refine((val) => {
|
|
107
|
+
if (val === null) return true
|
|
108
|
+
|
|
109
|
+
// Required check
|
|
110
|
+
if (required && (val === "" || val === "null" || val === "undefined")) {
|
|
111
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Format validation
|
|
115
|
+
if (val !== null && !dayjs(val, format, true).isValid()) {
|
|
116
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("format", { format }), path: [] }])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const dateObj = dayjs(val, format)
|
|
120
|
+
|
|
121
|
+
// Range checks
|
|
122
|
+
if (val !== null && min !== undefined && !dateObj.isSameOrAfter(dayjs(min, format))) {
|
|
123
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("min", { min }), path: [] }])
|
|
124
|
+
}
|
|
125
|
+
if (val !== null && max !== undefined && !dateObj.isSameOrBefore(dayjs(max, format))) {
|
|
126
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("max", { max }), path: [] }])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// String content checks
|
|
130
|
+
if (val !== null && includes !== undefined && !val.includes(includes)) {
|
|
131
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
|
|
132
|
+
}
|
|
133
|
+
if (val !== null && excludes !== undefined) {
|
|
134
|
+
const excludeList = Array.isArray(excludes) ? excludes : [excludes]
|
|
135
|
+
for (const exclude of excludeList) {
|
|
136
|
+
if (val.includes(exclude)) {
|
|
137
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Time-based validations
|
|
143
|
+
const today = dayjs().startOf('day')
|
|
144
|
+
const targetDate = dateObj.startOf('day')
|
|
145
|
+
|
|
146
|
+
if (val !== null && mustBePast && !targetDate.isBefore(today)) {
|
|
147
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("past"), path: [] }])
|
|
148
|
+
}
|
|
149
|
+
if (val !== null && mustBeFuture && !targetDate.isAfter(today)) {
|
|
150
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("future"), path: [] }])
|
|
151
|
+
}
|
|
152
|
+
if (val !== null && mustBeToday && !targetDate.isSame(today)) {
|
|
153
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("today"), path: [] }])
|
|
154
|
+
}
|
|
155
|
+
if (val !== null && mustNotBeToday && targetDate.isSame(today)) {
|
|
156
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("notToday"), path: [] }])
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Weekday/weekend validations
|
|
160
|
+
if (val !== null && weekdaysOnly && (dateObj.day() === 0 || dateObj.day() === 6)) {
|
|
161
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("weekday"), path: [] }])
|
|
162
|
+
}
|
|
163
|
+
if (val !== null && weekendsOnly && dateObj.day() !== 0 && dateObj.day() !== 6) {
|
|
164
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("weekend"), path: [] }])
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return true
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
return schema as unknown as DateSchema<IsRequired>
|
|
171
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
|
|
5
|
+
export type EmailMessages = {
|
|
6
|
+
required?: string
|
|
7
|
+
invalid?: string
|
|
8
|
+
minLength?: string
|
|
9
|
+
maxLength?: string
|
|
10
|
+
includes?: string
|
|
11
|
+
domain?: string
|
|
12
|
+
domainBlacklist?: string
|
|
13
|
+
businessOnly?: string
|
|
14
|
+
noDisposable?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type EmailOptions<IsRequired extends boolean = true> = {
|
|
18
|
+
required?: IsRequired
|
|
19
|
+
domain?: string | string[]
|
|
20
|
+
domainBlacklist?: string[]
|
|
21
|
+
minLength?: number
|
|
22
|
+
maxLength?: number
|
|
23
|
+
includes?: string
|
|
24
|
+
excludes?: string | string[]
|
|
25
|
+
allowSubdomains?: boolean
|
|
26
|
+
businessOnly?: boolean
|
|
27
|
+
noDisposable?: boolean
|
|
28
|
+
lowercase?: boolean
|
|
29
|
+
transform?: (value: string) => string
|
|
30
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
31
|
+
i18n?: Record<Locale, EmailMessages>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type EmailSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
35
|
+
|
|
36
|
+
export function email<IsRequired extends boolean = true>(options?: EmailOptions<IsRequired>): EmailSchema<IsRequired> {
|
|
37
|
+
const {
|
|
38
|
+
required = true,
|
|
39
|
+
domain,
|
|
40
|
+
domainBlacklist,
|
|
41
|
+
minLength,
|
|
42
|
+
maxLength,
|
|
43
|
+
includes,
|
|
44
|
+
excludes,
|
|
45
|
+
allowSubdomains = true,
|
|
46
|
+
businessOnly = false,
|
|
47
|
+
noDisposable = false,
|
|
48
|
+
lowercase = true,
|
|
49
|
+
transform,
|
|
50
|
+
defaultValue,
|
|
51
|
+
i18n,
|
|
52
|
+
} = options ?? {}
|
|
53
|
+
|
|
54
|
+
// Helper function to get custom message or fallback to default i18n
|
|
55
|
+
const getMessage = (key: keyof EmailMessages, params?: Record<string, any>) => {
|
|
56
|
+
if (i18n) {
|
|
57
|
+
const currentLocale = getLocale()
|
|
58
|
+
const customMessages = i18n[currentLocale]
|
|
59
|
+
if (customMessages && customMessages[key]) {
|
|
60
|
+
const template = customMessages[key]!
|
|
61
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return t(`common.email.${key}`, params)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Common disposable email domains
|
|
68
|
+
const disposableDomains = ["10minutemail.com", "tempmail.org", "guerrillamail.com", "mailinator.com", "yopmail.com", "temp-mail.org", "throwaway.email", "getnada.com", "maildrop.cc"]
|
|
69
|
+
|
|
70
|
+
// Common business email patterns (not free providers)
|
|
71
|
+
const freeEmailDomains = ["gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "icloud.com", "aol.com", "protonmail.com", "zoho.com"]
|
|
72
|
+
|
|
73
|
+
const actualDefaultValue = defaultValue ?? null
|
|
74
|
+
|
|
75
|
+
const baseSchema = z.preprocess(
|
|
76
|
+
(val) => {
|
|
77
|
+
if (val === "" || val === null || val === undefined) {
|
|
78
|
+
return actualDefaultValue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let processed = String(val).trim()
|
|
82
|
+
|
|
83
|
+
if (lowercase) {
|
|
84
|
+
processed = processed.toLowerCase()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (transform) {
|
|
88
|
+
processed = transform(processed)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return processed
|
|
92
|
+
},
|
|
93
|
+
z.union([z.string().email(), z.null()])
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const schema = baseSchema.refine((val) => {
|
|
97
|
+
// Required check first
|
|
98
|
+
if (required && val === null) {
|
|
99
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (val === null) return true
|
|
103
|
+
|
|
104
|
+
// Invalid email check
|
|
105
|
+
if (typeof val !== "string") {
|
|
106
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Length checks
|
|
110
|
+
if (minLength !== undefined && val.length < minLength) {
|
|
111
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("minLength", { minLength }), path: [] }])
|
|
112
|
+
}
|
|
113
|
+
if (maxLength !== undefined && val.length > maxLength) {
|
|
114
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("maxLength", { maxLength }), path: [] }])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Content checks
|
|
118
|
+
if (includes !== undefined && !val.includes(includes)) {
|
|
119
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (excludes !== undefined) {
|
|
123
|
+
const excludeList = Array.isArray(excludes) ? excludes : [excludes]
|
|
124
|
+
for (const exclude of excludeList) {
|
|
125
|
+
if (val.includes(exclude)) {
|
|
126
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes: exclude }), path: [] }])
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Extract domain from email
|
|
132
|
+
const emailDomain = val.split("@")[1]?.toLowerCase()
|
|
133
|
+
if (!emailDomain) {
|
|
134
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Business email check (should come before domain validation)
|
|
138
|
+
if (businessOnly) {
|
|
139
|
+
const isFreeProvider = freeEmailDomains.some((freeDomain) => {
|
|
140
|
+
if (allowSubdomains) {
|
|
141
|
+
return emailDomain === freeDomain || emailDomain.endsWith("." + freeDomain)
|
|
142
|
+
}
|
|
143
|
+
return emailDomain === freeDomain
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
if (isFreeProvider) {
|
|
147
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("businessOnly"), path: [] }])
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Domain blacklist
|
|
152
|
+
if (domainBlacklist && domainBlacklist.length > 0) {
|
|
153
|
+
const isBlacklisted = domainBlacklist.some((blacklistedDomain) => {
|
|
154
|
+
const lowerDomain = blacklistedDomain.toLowerCase()
|
|
155
|
+
if (allowSubdomains) {
|
|
156
|
+
return emailDomain === lowerDomain || emailDomain.endsWith("." + lowerDomain)
|
|
157
|
+
}
|
|
158
|
+
return emailDomain === lowerDomain
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
if (isBlacklisted) {
|
|
162
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("domainBlacklist", { domain: emailDomain }), path: [] }])
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Domain validation
|
|
167
|
+
if (domain !== undefined) {
|
|
168
|
+
const allowedDomains = Array.isArray(domain) ? domain : [domain]
|
|
169
|
+
const isAllowed = allowedDomains.some((allowedDomain) => {
|
|
170
|
+
const lowerDomain = allowedDomain.toLowerCase()
|
|
171
|
+
if (allowSubdomains) {
|
|
172
|
+
return emailDomain === lowerDomain || emailDomain.endsWith("." + lowerDomain)
|
|
173
|
+
}
|
|
174
|
+
return emailDomain === lowerDomain
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
if (!isAllowed) {
|
|
178
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("domain", { domain: Array.isArray(domain) ? domain.join(", ") : domain }), path: [] }])
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Disposable email check
|
|
183
|
+
if (noDisposable) {
|
|
184
|
+
const isDisposable = disposableDomains.some((disposableDomain) => {
|
|
185
|
+
if (allowSubdomains) {
|
|
186
|
+
return emailDomain === disposableDomain || emailDomain.endsWith("." + disposableDomain)
|
|
187
|
+
}
|
|
188
|
+
return emailDomain === disposableDomain
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
if (isDisposable) {
|
|
192
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noDisposable"), path: [] }])
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return true
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
return schema as unknown as EmailSchema<IsRequired>
|
|
200
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
|
|
5
|
+
export type IdMessages = {
|
|
6
|
+
required?: string
|
|
7
|
+
invalid?: string
|
|
8
|
+
minLength?: string
|
|
9
|
+
maxLength?: string
|
|
10
|
+
numeric?: string
|
|
11
|
+
uuid?: string
|
|
12
|
+
objectId?: string
|
|
13
|
+
nanoid?: string
|
|
14
|
+
snowflake?: string
|
|
15
|
+
cuid?: string
|
|
16
|
+
ulid?: string
|
|
17
|
+
shortid?: string
|
|
18
|
+
customFormat?: string
|
|
19
|
+
includes?: string
|
|
20
|
+
excludes?: string
|
|
21
|
+
startsWith?: string
|
|
22
|
+
endsWith?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type IdType =
|
|
26
|
+
| "numeric" // 純數字 ID (1, 123, 999999)
|
|
27
|
+
| "uuid" // UUID v4 格式
|
|
28
|
+
| "objectId" // MongoDB ObjectId (24位16進制)
|
|
29
|
+
| "nanoid" // Nano ID
|
|
30
|
+
| "snowflake" // Twitter Snowflake (19位數字)
|
|
31
|
+
| "cuid" // CUID 格式
|
|
32
|
+
| "ulid" // ULID 格式
|
|
33
|
+
| "shortid" // ShortId 格式
|
|
34
|
+
| "auto" // 自動檢測格式
|
|
35
|
+
|
|
36
|
+
export type IdOptions<IsRequired extends boolean = true> = {
|
|
37
|
+
required?: IsRequired
|
|
38
|
+
type?: IdType
|
|
39
|
+
minLength?: number
|
|
40
|
+
maxLength?: number
|
|
41
|
+
allowedTypes?: IdType[]
|
|
42
|
+
customRegex?: RegExp
|
|
43
|
+
includes?: string
|
|
44
|
+
excludes?: string | string[]
|
|
45
|
+
startsWith?: string
|
|
46
|
+
endsWith?: string
|
|
47
|
+
caseSensitive?: boolean
|
|
48
|
+
transform?: (value: string) => string
|
|
49
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
50
|
+
i18n?: Record<Locale, IdMessages>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type IdSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
54
|
+
|
|
55
|
+
// ID 格式的正則表達式
|
|
56
|
+
const ID_PATTERNS = {
|
|
57
|
+
numeric: /^\d+$/,
|
|
58
|
+
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
59
|
+
objectId: /^[0-9a-f]{24}$/i,
|
|
60
|
+
nanoid: /^[A-Za-z0-9_-]{21}$/,
|
|
61
|
+
snowflake: /^\d{19}$/,
|
|
62
|
+
cuid: /^c[a-z0-9]{24}$/,
|
|
63
|
+
ulid: /^[0-9A-HJKMNP-TV-Z]{26}$/,
|
|
64
|
+
shortid: /^[A-Za-z0-9_-]{7,14}$/,
|
|
65
|
+
} as const
|
|
66
|
+
|
|
67
|
+
// 檢測 ID 類型(按照特異性排序,避免誤判)
|
|
68
|
+
const detectIdType = (value: string): IdType | null => {
|
|
69
|
+
// 按優先順序檢查(從最具體到最通用)
|
|
70
|
+
const orderedTypes: Array<[IdType, RegExp]> = [
|
|
71
|
+
["uuid", ID_PATTERNS.uuid],
|
|
72
|
+
["objectId", ID_PATTERNS.objectId],
|
|
73
|
+
["snowflake", ID_PATTERNS.snowflake],
|
|
74
|
+
["cuid", ID_PATTERNS.cuid],
|
|
75
|
+
["ulid", ID_PATTERNS.ulid],
|
|
76
|
+
["nanoid", ID_PATTERNS.nanoid],
|
|
77
|
+
["numeric", ID_PATTERNS.numeric],
|
|
78
|
+
["shortid", ID_PATTERNS.shortid], // 放最後,因為最通用
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
for (const [type, pattern] of orderedTypes) {
|
|
82
|
+
if (pattern.test(value)) {
|
|
83
|
+
return type
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 驗證特定 ID 類型
|
|
90
|
+
const validateIdType = (value: string, type: IdType): boolean => {
|
|
91
|
+
if (type === "auto") {
|
|
92
|
+
return detectIdType(value) !== null
|
|
93
|
+
}
|
|
94
|
+
const pattern = ID_PATTERNS[type as keyof typeof ID_PATTERNS]
|
|
95
|
+
return pattern ? pattern.test(value) : false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function id<IsRequired extends boolean = true>(options?: IdOptions<IsRequired>): IdSchema<IsRequired> {
|
|
99
|
+
const {
|
|
100
|
+
required = true,
|
|
101
|
+
type = "auto",
|
|
102
|
+
minLength,
|
|
103
|
+
maxLength,
|
|
104
|
+
allowedTypes,
|
|
105
|
+
customRegex,
|
|
106
|
+
includes,
|
|
107
|
+
excludes,
|
|
108
|
+
startsWith,
|
|
109
|
+
endsWith,
|
|
110
|
+
caseSensitive = true,
|
|
111
|
+
transform,
|
|
112
|
+
defaultValue,
|
|
113
|
+
i18n,
|
|
114
|
+
} = options ?? {}
|
|
115
|
+
|
|
116
|
+
// Set appropriate default value based on required flag
|
|
117
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
118
|
+
|
|
119
|
+
// Helper function to get custom message or fallback to default i18n
|
|
120
|
+
const getMessage = (key: keyof IdMessages, params?: Record<string, any>) => {
|
|
121
|
+
if (i18n) {
|
|
122
|
+
const currentLocale = getLocale()
|
|
123
|
+
const customMessages = i18n[currentLocale]
|
|
124
|
+
if (customMessages && customMessages[key]) {
|
|
125
|
+
const template = customMessages[key]!
|
|
126
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return t(`common.id.${key}`, params)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Preprocessing function
|
|
133
|
+
const preprocessFn = (val: unknown) => {
|
|
134
|
+
if (val === "" || val === null || val === undefined) {
|
|
135
|
+
return actualDefaultValue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let processed = String(val)
|
|
139
|
+
|
|
140
|
+
if (transform) {
|
|
141
|
+
processed = transform(processed)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return processed
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
148
|
+
|
|
149
|
+
const schema = baseSchema
|
|
150
|
+
.refine((val) => {
|
|
151
|
+
if (val === null) return true
|
|
152
|
+
|
|
153
|
+
// Required check
|
|
154
|
+
if (required && (val === "" || val === "null" || val === "undefined")) {
|
|
155
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Create comparison value for case-insensitive checks
|
|
159
|
+
const comparisonVal = !caseSensitive ? val.toLowerCase() : val
|
|
160
|
+
|
|
161
|
+
// Length checks
|
|
162
|
+
if (val !== null && minLength !== undefined && val.length < minLength) {
|
|
163
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("minLength", { minLength }), path: [] }])
|
|
164
|
+
}
|
|
165
|
+
if (val !== null && maxLength !== undefined && val.length > maxLength) {
|
|
166
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("maxLength", { maxLength }), path: [] }])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check if we have content-based validations that override format checking
|
|
170
|
+
const hasContentValidations = customRegex !== undefined || startsWith !== undefined || endsWith !== undefined || includes !== undefined || excludes !== undefined
|
|
171
|
+
|
|
172
|
+
// Custom regex validation (overrides ID format validation)
|
|
173
|
+
if (val !== null && customRegex !== undefined) {
|
|
174
|
+
if (!customRegex.test(val)) {
|
|
175
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("customFormat"), path: [] }])
|
|
176
|
+
}
|
|
177
|
+
} else if (val !== null && !hasContentValidations) {
|
|
178
|
+
// ID type validation (only if no custom regex or content validations)
|
|
179
|
+
let isValidId: boolean
|
|
180
|
+
|
|
181
|
+
if (allowedTypes && allowedTypes.length > 0) {
|
|
182
|
+
// Check if ID matches any of the allowed types
|
|
183
|
+
isValidId = allowedTypes.some((allowedType) => validateIdType(val, allowedType))
|
|
184
|
+
if (!isValidId) {
|
|
185
|
+
const typeNames = allowedTypes.join(", ")
|
|
186
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid") + ` (allowed types: ${typeNames})`, path: [] }])
|
|
187
|
+
}
|
|
188
|
+
} else if (type !== "auto") {
|
|
189
|
+
// Validate specific type
|
|
190
|
+
isValidId = validateIdType(val, type)
|
|
191
|
+
if (!isValidId) {
|
|
192
|
+
throw new z.ZodError([{ code: "custom", message: getMessage(type as keyof IdMessages) || getMessage("invalid"), path: [] }])
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
// Auto-detection - must match at least one known pattern
|
|
196
|
+
isValidId = detectIdType(val) !== null
|
|
197
|
+
if (!isValidId) {
|
|
198
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} else if (val !== null && hasContentValidations && type !== "auto" && !customRegex) {
|
|
202
|
+
// Still validate specific types even with content validations (but not auto)
|
|
203
|
+
if (allowedTypes && allowedTypes.length > 0) {
|
|
204
|
+
const isValidType = allowedTypes.some((allowedType) => validateIdType(val, allowedType))
|
|
205
|
+
if (!isValidType) {
|
|
206
|
+
const typeNames = allowedTypes.join(", ")
|
|
207
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid") + ` (allowed types: ${typeNames})`, path: [] }])
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
if (!validateIdType(val, type)) {
|
|
211
|
+
throw new z.ZodError([{ code: "custom", message: getMessage(type as keyof IdMessages) || getMessage("invalid"), path: [] }])
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// String content checks (using comparisonVal for case sensitivity)
|
|
217
|
+
const searchStartsWith = !caseSensitive && startsWith ? startsWith.toLowerCase() : startsWith
|
|
218
|
+
const searchEndsWith = !caseSensitive && endsWith ? endsWith.toLowerCase() : endsWith
|
|
219
|
+
const searchIncludes = !caseSensitive && includes ? includes.toLowerCase() : includes
|
|
220
|
+
|
|
221
|
+
if (val !== null && startsWith !== undefined && !comparisonVal.startsWith(searchStartsWith!)) {
|
|
222
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("startsWith", { startsWith }), path: [] }])
|
|
223
|
+
}
|
|
224
|
+
if (val !== null && endsWith !== undefined && !comparisonVal.endsWith(searchEndsWith!)) {
|
|
225
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("endsWith", { endsWith }), path: [] }])
|
|
226
|
+
}
|
|
227
|
+
if (val !== null && includes !== undefined && !comparisonVal.includes(searchIncludes!)) {
|
|
228
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
|
|
229
|
+
}
|
|
230
|
+
if (val !== null && excludes !== undefined) {
|
|
231
|
+
const excludeList = Array.isArray(excludes) ? excludes : [excludes]
|
|
232
|
+
for (const exclude of excludeList) {
|
|
233
|
+
const searchExclude = !caseSensitive ? exclude.toLowerCase() : exclude
|
|
234
|
+
if (comparisonVal.includes(searchExclude)) {
|
|
235
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return true
|
|
241
|
+
})
|
|
242
|
+
.transform((val) => {
|
|
243
|
+
if (val === null) return val
|
|
244
|
+
|
|
245
|
+
// Handle case transformations
|
|
246
|
+
const shouldPreserveCase = type === "uuid" || type === "objectId"
|
|
247
|
+
|
|
248
|
+
if (!caseSensitive && !shouldPreserveCase) {
|
|
249
|
+
return val.toLowerCase()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return val // preserve the original case for UUID/ObjectId or when case-sensitive
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
return schema as unknown as IdSchema<IsRequired>
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Export utility functions for external use
|
|
259
|
+
export { detectIdType, validateIdType, ID_PATTERNS }
|