@hy_ong/zod-kit 0.0.4 → 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 +5 -5
- package/debug.js +21 -0
- package/debug.ts +16 -0
- package/dist/index.cjs +1619 -145
- package/dist/index.d.cts +324 -25
- package/dist/index.d.ts +324 -25
- package/dist/index.js +1590 -143
- package/eslint.config.mts +8 -0
- package/package.json +10 -9
- package/src/config.ts +1 -1
- package/src/i18n/locales/en.json +99 -25
- package/src/i18n/locales/zh-TW.json +103 -26
- 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 +271 -44
- package/tests/common/text.test.ts +210 -13
- 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 -36
- package/src/common/date.ts +0 -43
- package/src/common/email.ts +0 -44
- package/src/common/integer.ts +0 -46
- package/src/common/number.ts +0 -37
- package/src/common/password.ts +0 -33
- package/src/common/text.ts +0 -34
- package/src/common/url.ts +0 -37
- package/tests/common/integer.test.ts +0 -90
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodNumber } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
|
|
5
|
+
export type NumberMessages = {
|
|
6
|
+
required?: string
|
|
7
|
+
invalid?: string
|
|
8
|
+
integer?: string
|
|
9
|
+
float?: string
|
|
10
|
+
min?: string
|
|
11
|
+
max?: string
|
|
12
|
+
positive?: string
|
|
13
|
+
negative?: string
|
|
14
|
+
nonNegative?: string
|
|
15
|
+
nonPositive?: string
|
|
16
|
+
multipleOf?: string
|
|
17
|
+
finite?: string
|
|
18
|
+
precision?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type NumberOptions<IsRequired extends boolean = true> = {
|
|
22
|
+
required?: IsRequired
|
|
23
|
+
min?: number
|
|
24
|
+
max?: number
|
|
25
|
+
defaultValue?: IsRequired extends true ? number : number | null
|
|
26
|
+
type?: "integer" | "float" | "both"
|
|
27
|
+
positive?: boolean
|
|
28
|
+
negative?: boolean
|
|
29
|
+
nonNegative?: boolean
|
|
30
|
+
nonPositive?: boolean
|
|
31
|
+
multipleOf?: number
|
|
32
|
+
precision?: number
|
|
33
|
+
finite?: boolean
|
|
34
|
+
transform?: (value: number) => number
|
|
35
|
+
parseCommas?: boolean // Parse "1,234" as 1234
|
|
36
|
+
i18n?: Record<Locale, NumberMessages>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type NumberSchema<IsRequired extends boolean> = IsRequired extends true ? ZodNumber : ZodNullable<ZodNumber>
|
|
40
|
+
|
|
41
|
+
export function number<IsRequired extends boolean = true>(options?: NumberOptions<IsRequired>): NumberSchema<IsRequired> {
|
|
42
|
+
const {
|
|
43
|
+
required = true,
|
|
44
|
+
min,
|
|
45
|
+
max,
|
|
46
|
+
defaultValue,
|
|
47
|
+
type = "both",
|
|
48
|
+
positive,
|
|
49
|
+
negative,
|
|
50
|
+
nonNegative,
|
|
51
|
+
nonPositive,
|
|
52
|
+
multipleOf,
|
|
53
|
+
precision,
|
|
54
|
+
finite = true,
|
|
55
|
+
transform,
|
|
56
|
+
parseCommas = false,
|
|
57
|
+
i18n,
|
|
58
|
+
} = options ?? {}
|
|
59
|
+
|
|
60
|
+
// Helper function to get custom message or fallback to default i18n
|
|
61
|
+
const getMessage = (key: keyof NumberMessages, params?: Record<string, any>) => {
|
|
62
|
+
if (i18n) {
|
|
63
|
+
const currentLocale = getLocale()
|
|
64
|
+
const customMessages = i18n[currentLocale]
|
|
65
|
+
if (customMessages && customMessages[key]) {
|
|
66
|
+
const template = customMessages[key]!
|
|
67
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return t(`common.number.${key}`, params)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Set appropriate default value based on required flag
|
|
74
|
+
const actualDefaultValue = defaultValue ?? null
|
|
75
|
+
|
|
76
|
+
const schema = z
|
|
77
|
+
.preprocess(
|
|
78
|
+
(val) => {
|
|
79
|
+
if (val === "" || val === undefined || val === null) {
|
|
80
|
+
return actualDefaultValue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Handle string input
|
|
84
|
+
if (typeof val === "string") {
|
|
85
|
+
let processedVal = val.trim()
|
|
86
|
+
|
|
87
|
+
// Parse comma-separated numbers like "1,234.56"
|
|
88
|
+
if (parseCommas) {
|
|
89
|
+
processedVal = processedVal.replace(/,/g, "")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parsed = Number(processedVal)
|
|
93
|
+
|
|
94
|
+
// Return NaN as is so it can be caught in refine
|
|
95
|
+
if (isNaN(parsed)) {
|
|
96
|
+
return parsed
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (transform) {
|
|
100
|
+
return transform(parsed)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return parsed
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Handle existing numbers (including Infinity)
|
|
107
|
+
if (typeof val === "number") {
|
|
108
|
+
if (transform && Number.isFinite(val)) {
|
|
109
|
+
return transform(val)
|
|
110
|
+
}
|
|
111
|
+
return val
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return val
|
|
115
|
+
},
|
|
116
|
+
z.union([z.number(), z.null(), z.nan(), z.custom<number>((val) => val === Infinity || val === -Infinity)])
|
|
117
|
+
)
|
|
118
|
+
.refine((val) => {
|
|
119
|
+
// Required check first
|
|
120
|
+
if (required && val === null) {
|
|
121
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (val === null) return true
|
|
125
|
+
|
|
126
|
+
// Type validation for invalid inputs (NaN)
|
|
127
|
+
if (typeof val === "number" && isNaN(val)) {
|
|
128
|
+
if (type === "integer") {
|
|
129
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("integer"), path: [] }])
|
|
130
|
+
} else if (type === "float") {
|
|
131
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("float"), path: [] }])
|
|
132
|
+
} else {
|
|
133
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Invalid number check for non-numbers
|
|
138
|
+
if (typeof val !== "number") {
|
|
139
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Finite check
|
|
143
|
+
if (finite && !Number.isFinite(val)) {
|
|
144
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("finite"), path: [] }])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Type validation for valid numbers
|
|
148
|
+
if (type === "integer" && !Number.isInteger(val)) {
|
|
149
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("integer"), path: [] }])
|
|
150
|
+
}
|
|
151
|
+
if (type === "float" && Number.isInteger(val)) {
|
|
152
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("float"), path: [] }])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Sign checks (more specific, should come first)
|
|
156
|
+
if (positive && val <= 0) {
|
|
157
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("positive"), path: [] }])
|
|
158
|
+
}
|
|
159
|
+
if (negative && val >= 0) {
|
|
160
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("negative"), path: [] }])
|
|
161
|
+
}
|
|
162
|
+
if (nonNegative && val < 0) {
|
|
163
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("nonNegative"), path: [] }])
|
|
164
|
+
}
|
|
165
|
+
if (nonPositive && val > 0) {
|
|
166
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("nonPositive"), path: [] }])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Range checks
|
|
170
|
+
if (min !== undefined && val < min) {
|
|
171
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("min", { min }), path: [] }])
|
|
172
|
+
}
|
|
173
|
+
if (max !== undefined && val > max) {
|
|
174
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("max", { max }), path: [] }])
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Multiple of check
|
|
178
|
+
if (multipleOf !== undefined && val % multipleOf !== 0) {
|
|
179
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("multipleOf", { multipleOf }), path: [] }])
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Precision check
|
|
183
|
+
if (precision !== undefined) {
|
|
184
|
+
const decimalPlaces = (val.toString().split(".")[1] || "").length
|
|
185
|
+
if (decimalPlaces > precision) {
|
|
186
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("precision", { precision }), path: [] }])
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return true
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
return schema as unknown as NumberSchema<IsRequired>
|
|
194
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
|
|
5
|
+
export type PasswordMessages = {
|
|
6
|
+
required?: string
|
|
7
|
+
min?: string
|
|
8
|
+
max?: string
|
|
9
|
+
uppercase?: string
|
|
10
|
+
lowercase?: string
|
|
11
|
+
digits?: string
|
|
12
|
+
special?: string
|
|
13
|
+
noRepeating?: string
|
|
14
|
+
noSequential?: string
|
|
15
|
+
noCommonWords?: string
|
|
16
|
+
minStrength?: string
|
|
17
|
+
excludes?: string
|
|
18
|
+
includes?: string
|
|
19
|
+
invalid?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type PasswordStrength = "weak" | "medium" | "strong" | "very-strong"
|
|
23
|
+
|
|
24
|
+
export type PasswordOptions<IsRequired extends boolean = true> = {
|
|
25
|
+
required?: IsRequired
|
|
26
|
+
min?: number
|
|
27
|
+
max?: number
|
|
28
|
+
uppercase?: boolean
|
|
29
|
+
lowercase?: boolean
|
|
30
|
+
digits?: boolean
|
|
31
|
+
special?: boolean
|
|
32
|
+
noRepeating?: boolean
|
|
33
|
+
noSequential?: boolean
|
|
34
|
+
noCommonWords?: boolean
|
|
35
|
+
minStrength?: PasswordStrength
|
|
36
|
+
excludes?: string | string[]
|
|
37
|
+
includes?: string
|
|
38
|
+
regex?: RegExp
|
|
39
|
+
transform?: (value: string) => string
|
|
40
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
41
|
+
i18n?: Record<Locale, PasswordMessages>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type PasswordSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
45
|
+
|
|
46
|
+
// Common weak passwords to check against
|
|
47
|
+
const COMMON_PASSWORDS = [
|
|
48
|
+
"password",
|
|
49
|
+
"123456",
|
|
50
|
+
"123456789",
|
|
51
|
+
"12345678",
|
|
52
|
+
"12345",
|
|
53
|
+
"1234567",
|
|
54
|
+
"admin",
|
|
55
|
+
"qwerty",
|
|
56
|
+
"abc123",
|
|
57
|
+
"password123",
|
|
58
|
+
"letmein",
|
|
59
|
+
"welcome",
|
|
60
|
+
"monkey",
|
|
61
|
+
"dragon",
|
|
62
|
+
"sunshine",
|
|
63
|
+
"princess",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
// Helper function to calculate password strength
|
|
67
|
+
const calculatePasswordStrength = (password: string): PasswordStrength => {
|
|
68
|
+
let score = 0
|
|
69
|
+
|
|
70
|
+
// Length bonus
|
|
71
|
+
if (password.length >= 8) score += 1
|
|
72
|
+
if (password.length >= 12) score += 1
|
|
73
|
+
if (password.length >= 16) score += 1
|
|
74
|
+
|
|
75
|
+
// Character variety
|
|
76
|
+
if (/[a-z]/.test(password)) score += 1
|
|
77
|
+
if (/[A-Z]/.test(password)) score += 1
|
|
78
|
+
if (/[0-9]/.test(password)) score += 1
|
|
79
|
+
if (/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) score += 1
|
|
80
|
+
|
|
81
|
+
// Deductions
|
|
82
|
+
if (/(.)\1{2,}/.test(password)) score -= 1 // Repeating characters
|
|
83
|
+
if (/(?:abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz|012|123|234|345|456|567|678|789)/i.test(password)) score -= 1 // Sequential
|
|
84
|
+
|
|
85
|
+
if (score <= 2) return "weak"
|
|
86
|
+
if (score <= 4) return "medium"
|
|
87
|
+
if (score <= 6) return "strong"
|
|
88
|
+
return "very-strong"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function password<IsRequired extends boolean = true>(options?: PasswordOptions<IsRequired>): PasswordSchema<IsRequired> {
|
|
92
|
+
const {
|
|
93
|
+
required = true,
|
|
94
|
+
min,
|
|
95
|
+
max,
|
|
96
|
+
uppercase,
|
|
97
|
+
lowercase,
|
|
98
|
+
digits,
|
|
99
|
+
special,
|
|
100
|
+
noRepeating,
|
|
101
|
+
noSequential,
|
|
102
|
+
noCommonWords,
|
|
103
|
+
minStrength,
|
|
104
|
+
excludes,
|
|
105
|
+
includes,
|
|
106
|
+
regex,
|
|
107
|
+
transform,
|
|
108
|
+
defaultValue,
|
|
109
|
+
i18n,
|
|
110
|
+
} = options ?? {}
|
|
111
|
+
|
|
112
|
+
// Set appropriate default value based on required flag
|
|
113
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
114
|
+
|
|
115
|
+
// Helper function to get custom message or fallback to default i18n
|
|
116
|
+
const getMessage = (key: keyof PasswordMessages, params?: Record<string, any>) => {
|
|
117
|
+
if (i18n) {
|
|
118
|
+
const currentLocale = getLocale()
|
|
119
|
+
const customMessages = i18n[currentLocale]
|
|
120
|
+
if (customMessages && customMessages[key]) {
|
|
121
|
+
const template = customMessages[key]!
|
|
122
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return t(`common.password.${key}`, params)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Preprocessing function
|
|
129
|
+
const preprocessFn = (val: unknown) => {
|
|
130
|
+
if (val === "" || val === null || val === undefined) {
|
|
131
|
+
return actualDefaultValue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let processed = String(val)
|
|
135
|
+
if (transform) {
|
|
136
|
+
processed = transform(processed)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return processed
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
143
|
+
|
|
144
|
+
const schema = baseSchema.refine((val) => {
|
|
145
|
+
if (val === null) return true
|
|
146
|
+
|
|
147
|
+
// Required check
|
|
148
|
+
if (required && (val === "" || val === "null" || val === "undefined")) {
|
|
149
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Length checks
|
|
153
|
+
if (val !== null && min !== undefined && val.length < min) {
|
|
154
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("min", { min }), path: [] }])
|
|
155
|
+
}
|
|
156
|
+
if (val !== null && max !== undefined && val.length > max) {
|
|
157
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("max", { max }), path: [] }])
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Character requirements
|
|
161
|
+
if (val !== null && uppercase && !/[A-Z]/.test(val)) {
|
|
162
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("uppercase"), path: [] }])
|
|
163
|
+
}
|
|
164
|
+
if (val !== null && lowercase && !/[a-z]/.test(val)) {
|
|
165
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("lowercase"), path: [] }])
|
|
166
|
+
}
|
|
167
|
+
if (val !== null && digits && !/[0-9]/.test(val)) {
|
|
168
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("digits"), path: [] }])
|
|
169
|
+
}
|
|
170
|
+
if (val !== null && special && !/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(val)) {
|
|
171
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("special"), path: [] }])
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Advanced security checks
|
|
175
|
+
if (val !== null && noRepeating && /(.)\1{2,}/.test(val)) {
|
|
176
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noRepeating"), path: [] }])
|
|
177
|
+
}
|
|
178
|
+
if (val !== null && noSequential && /(?:abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz|012|123|234|345|456|567|678|789)/i.test(val)) {
|
|
179
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noSequential"), path: [] }])
|
|
180
|
+
}
|
|
181
|
+
if (val !== null && noCommonWords && COMMON_PASSWORDS.some((common) => val.toLowerCase().includes(common.toLowerCase()))) {
|
|
182
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noCommonWords"), path: [] }])
|
|
183
|
+
}
|
|
184
|
+
if (val !== null && minStrength) {
|
|
185
|
+
const strength = calculatePasswordStrength(val)
|
|
186
|
+
const strengthLevels = ["weak", "medium", "strong", "very-strong"]
|
|
187
|
+
const currentLevel = strengthLevels.indexOf(strength)
|
|
188
|
+
const requiredLevel = strengthLevels.indexOf(minStrength)
|
|
189
|
+
if (currentLevel < requiredLevel) {
|
|
190
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("minStrength", { minStrength }), path: [] }])
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Content checks
|
|
195
|
+
if (val !== null && includes !== undefined && !val.includes(includes)) {
|
|
196
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
|
|
197
|
+
}
|
|
198
|
+
if (val !== null && excludes !== undefined) {
|
|
199
|
+
const excludeList = Array.isArray(excludes) ? excludes : [excludes]
|
|
200
|
+
for (const exclude of excludeList) {
|
|
201
|
+
if (val.includes(exclude)) {
|
|
202
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (val !== null && regex !== undefined && !regex.test(val)) {
|
|
207
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid", { regex }), path: [] }])
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return true
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return schema as unknown as PasswordSchema<IsRequired>
|
|
214
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
|
|
5
|
+
export type TextMessages = {
|
|
6
|
+
required?: string
|
|
7
|
+
notEmpty?: string
|
|
8
|
+
minLength?: string
|
|
9
|
+
maxLength?: string
|
|
10
|
+
startsWith?: string
|
|
11
|
+
endsWith?: string
|
|
12
|
+
includes?: string
|
|
13
|
+
excludes?: string
|
|
14
|
+
invalid?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TextOptions<IsRequired extends boolean = true> = {
|
|
18
|
+
required?: IsRequired
|
|
19
|
+
minLength?: number
|
|
20
|
+
maxLength?: number
|
|
21
|
+
startsWith?: string
|
|
22
|
+
endsWith?: string
|
|
23
|
+
includes?: string
|
|
24
|
+
excludes?: string | string[]
|
|
25
|
+
regex?: RegExp
|
|
26
|
+
trimMode?: "trim" | "trimStart" | "trimEnd" | "none"
|
|
27
|
+
casing?: "upper" | "lower" | "title" | "none"
|
|
28
|
+
transform?: (value: string) => string
|
|
29
|
+
notEmpty?: boolean
|
|
30
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
31
|
+
i18n?: Record<Locale, TextMessages>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type TextSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
35
|
+
|
|
36
|
+
export function text<IsRequired extends boolean = true>(options?: TextOptions<IsRequired>): TextSchema<IsRequired> {
|
|
37
|
+
const { required = true, minLength, maxLength, startsWith, endsWith, includes, excludes, regex, trimMode = "trim", casing = "none", transform, notEmpty, defaultValue, i18n } = options ?? {}
|
|
38
|
+
|
|
39
|
+
// Set appropriate default value based on required flag
|
|
40
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
41
|
+
|
|
42
|
+
// Helper function to get custom message or fallback to default i18n
|
|
43
|
+
const getMessage = (key: keyof TextMessages, params?: Record<string, any>) => {
|
|
44
|
+
if (i18n) {
|
|
45
|
+
const currentLocale = getLocale()
|
|
46
|
+
const customMessages = i18n[currentLocale]
|
|
47
|
+
if (customMessages && customMessages[key]) {
|
|
48
|
+
const template = customMessages[key]!
|
|
49
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return t(`common.text.${key}`, params)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Helper functions to apply trimming based on the mode
|
|
56
|
+
const applyTrim = (str: string): string => {
|
|
57
|
+
switch (trimMode) {
|
|
58
|
+
case "trimStart":
|
|
59
|
+
return str.trimStart()
|
|
60
|
+
case "trimEnd":
|
|
61
|
+
return str.trimEnd()
|
|
62
|
+
case "none":
|
|
63
|
+
return str
|
|
64
|
+
default:
|
|
65
|
+
return str.trim()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Helper function to apply casing
|
|
70
|
+
const applyCasing = (str: string): string => {
|
|
71
|
+
switch (casing) {
|
|
72
|
+
case "upper":
|
|
73
|
+
return str.toUpperCase()
|
|
74
|
+
case "lower":
|
|
75
|
+
return str.toLowerCase()
|
|
76
|
+
case "title":
|
|
77
|
+
return str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase())
|
|
78
|
+
default:
|
|
79
|
+
return str
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Preprocessing function with optimized transformations
|
|
84
|
+
const preprocessFn = (val: unknown) => {
|
|
85
|
+
if (val === "" || val === null || val === undefined) {
|
|
86
|
+
return actualDefaultValue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let processed = String(val)
|
|
90
|
+
processed = applyTrim(processed)
|
|
91
|
+
processed = applyCasing(processed)
|
|
92
|
+
|
|
93
|
+
if (transform) {
|
|
94
|
+
processed = transform(processed)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return processed
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
101
|
+
|
|
102
|
+
// Single refine with all validations for better performance
|
|
103
|
+
const schema = baseSchema.refine((val) => {
|
|
104
|
+
if (val === null) return true
|
|
105
|
+
|
|
106
|
+
// Required check
|
|
107
|
+
if (required && (val === "" || val === "null" || val === "undefined")) {
|
|
108
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Not empty check (different from required - checks whitespace)
|
|
112
|
+
// For notEmpty, we need to check if the original string (before processing) was only whitespace
|
|
113
|
+
if (notEmpty && val !== null && val.trim() === "") {
|
|
114
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("notEmpty"), path: [] }])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Length checks
|
|
118
|
+
if (val !== null && minLength !== undefined && val.length < minLength) {
|
|
119
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("minLength", { minLength }), path: [] }])
|
|
120
|
+
}
|
|
121
|
+
if (val !== null && maxLength !== undefined && val.length > maxLength) {
|
|
122
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("maxLength", { maxLength }), path: [] }])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// String content checks
|
|
126
|
+
if (val !== null && startsWith !== undefined && !val.startsWith(startsWith)) {
|
|
127
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("startsWith", { startsWith }), path: [] }])
|
|
128
|
+
}
|
|
129
|
+
if (val !== null && endsWith !== undefined && !val.endsWith(endsWith)) {
|
|
130
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("endsWith", { endsWith }), path: [] }])
|
|
131
|
+
}
|
|
132
|
+
if (val !== null && includes !== undefined && !val.includes(includes)) {
|
|
133
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
|
|
134
|
+
}
|
|
135
|
+
if (val !== null && excludes !== undefined) {
|
|
136
|
+
const excludeList = Array.isArray(excludes) ? excludes : [excludes]
|
|
137
|
+
for (const exclude of excludeList) {
|
|
138
|
+
if (val.includes(exclude)) {
|
|
139
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (val !== null && regex !== undefined && !regex.test(val)) {
|
|
144
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid", { regex }), path: [] }])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return true
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return schema as unknown as TextSchema<IsRequired>
|
|
151
|
+
}
|