@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,207 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
|
|
5
|
+
export type UrlMessages = {
|
|
6
|
+
required?: string
|
|
7
|
+
invalid?: string
|
|
8
|
+
min?: string
|
|
9
|
+
max?: string
|
|
10
|
+
includes?: string
|
|
11
|
+
excludes?: string
|
|
12
|
+
protocol?: string
|
|
13
|
+
domain?: string
|
|
14
|
+
domainBlacklist?: string
|
|
15
|
+
port?: string
|
|
16
|
+
pathStartsWith?: string
|
|
17
|
+
pathEndsWith?: string
|
|
18
|
+
hasQuery?: string
|
|
19
|
+
noQuery?: string
|
|
20
|
+
hasFragment?: string
|
|
21
|
+
noFragment?: string
|
|
22
|
+
localhost?: string
|
|
23
|
+
noLocalhost?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type UrlOptions<IsRequired extends boolean = true> = {
|
|
27
|
+
required?: IsRequired
|
|
28
|
+
min?: number
|
|
29
|
+
max?: number
|
|
30
|
+
includes?: string
|
|
31
|
+
excludes?: string | string[]
|
|
32
|
+
protocols?: string[]
|
|
33
|
+
allowedDomains?: string[]
|
|
34
|
+
blockedDomains?: string[]
|
|
35
|
+
allowedPorts?: number[]
|
|
36
|
+
blockedPorts?: number[]
|
|
37
|
+
pathStartsWith?: string
|
|
38
|
+
pathEndsWith?: string
|
|
39
|
+
mustHaveQuery?: boolean
|
|
40
|
+
mustNotHaveQuery?: boolean
|
|
41
|
+
mustHaveFragment?: boolean
|
|
42
|
+
mustNotHaveFragment?: boolean
|
|
43
|
+
allowLocalhost?: boolean
|
|
44
|
+
blockLocalhost?: boolean
|
|
45
|
+
transform?: (value: string) => string
|
|
46
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
47
|
+
i18n?: Record<Locale, UrlMessages>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type UrlSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
51
|
+
|
|
52
|
+
export function url<IsRequired extends boolean = true>(options?: UrlOptions<IsRequired>): UrlSchema<IsRequired> {
|
|
53
|
+
const {
|
|
54
|
+
required = true,
|
|
55
|
+
min,
|
|
56
|
+
max,
|
|
57
|
+
includes,
|
|
58
|
+
excludes,
|
|
59
|
+
protocols,
|
|
60
|
+
allowedDomains,
|
|
61
|
+
blockedDomains,
|
|
62
|
+
allowedPorts,
|
|
63
|
+
blockedPorts,
|
|
64
|
+
pathStartsWith,
|
|
65
|
+
pathEndsWith,
|
|
66
|
+
mustHaveQuery,
|
|
67
|
+
mustNotHaveQuery,
|
|
68
|
+
mustHaveFragment,
|
|
69
|
+
mustNotHaveFragment,
|
|
70
|
+
allowLocalhost = true,
|
|
71
|
+
blockLocalhost,
|
|
72
|
+
transform,
|
|
73
|
+
defaultValue = null,
|
|
74
|
+
i18n,
|
|
75
|
+
} = options ?? {}
|
|
76
|
+
|
|
77
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
78
|
+
|
|
79
|
+
// Helper function to get custom message or fallback to default i18n
|
|
80
|
+
const getMessage = (key: keyof UrlMessages, params?: Record<string, any>) => {
|
|
81
|
+
if (i18n) {
|
|
82
|
+
const currentLocale = getLocale()
|
|
83
|
+
const customMessages = i18n[currentLocale]
|
|
84
|
+
if (customMessages && customMessages[key]) {
|
|
85
|
+
const template = customMessages[key]!
|
|
86
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return t(`common.url.${key}`, params)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Preprocessing function with transformations
|
|
93
|
+
const preprocessFn = (val: unknown) => {
|
|
94
|
+
if (val === "" || val === null || val === undefined) {
|
|
95
|
+
return actualDefaultValue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let processed = String(val).trim()
|
|
99
|
+
|
|
100
|
+
if (transform) {
|
|
101
|
+
processed = transform(processed)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return processed
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
108
|
+
|
|
109
|
+
const schema = baseSchema.refine((val) => {
|
|
110
|
+
if (val === null) return true
|
|
111
|
+
|
|
112
|
+
// Required check
|
|
113
|
+
if (required && (val === "" || val === "null" || val === "undefined")) {
|
|
114
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// URL format validation
|
|
118
|
+
let urlObj: URL
|
|
119
|
+
try {
|
|
120
|
+
urlObj = new URL(val)
|
|
121
|
+
} catch {
|
|
122
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Length checks
|
|
126
|
+
if (val !== null && min !== undefined && val.length < min) {
|
|
127
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("min", { min }), path: [] }])
|
|
128
|
+
}
|
|
129
|
+
if (val !== null && max !== undefined && val.length > max) {
|
|
130
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("max", { max }), path: [] }])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// String content checks
|
|
134
|
+
if (val !== null && includes !== undefined && !val.includes(includes)) {
|
|
135
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
|
|
136
|
+
}
|
|
137
|
+
if (val !== null && excludes !== undefined) {
|
|
138
|
+
const excludeList = Array.isArray(excludes) ? excludes : [excludes]
|
|
139
|
+
for (const exclude of excludeList) {
|
|
140
|
+
if (val.includes(exclude)) {
|
|
141
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Protocol validation
|
|
147
|
+
if (protocols && !protocols.includes(urlObj.protocol.slice(0, -1))) {
|
|
148
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("protocol", { protocols: protocols.join(", ") }), path: [] }])
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Domain validation
|
|
152
|
+
const hostname = urlObj.hostname.toLowerCase()
|
|
153
|
+
if (allowedDomains && !allowedDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`))) {
|
|
154
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("domain", { domains: allowedDomains.join(", ") }), path: [] }])
|
|
155
|
+
}
|
|
156
|
+
if (blockedDomains && blockedDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`))) {
|
|
157
|
+
const blockedDomain = blockedDomains.find((domain) => hostname === domain || hostname.endsWith(`.${domain}`))
|
|
158
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("domainBlacklist", { domain: blockedDomain }), path: [] }])
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Port validation
|
|
162
|
+
const port = urlObj.port ? parseInt(urlObj.port) : urlObj.protocol === "https:" ? 443 : 80
|
|
163
|
+
if (allowedPorts && !allowedPorts.includes(port)) {
|
|
164
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("port", { ports: allowedPorts.join(", ") }), path: [] }])
|
|
165
|
+
}
|
|
166
|
+
if (blockedPorts && blockedPorts.includes(port)) {
|
|
167
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("port", { port }), path: [] }])
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Path validation
|
|
171
|
+
if (pathStartsWith && !urlObj.pathname.startsWith(pathStartsWith)) {
|
|
172
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("pathStartsWith", { path: pathStartsWith }), path: [] }])
|
|
173
|
+
}
|
|
174
|
+
if (pathEndsWith && !urlObj.pathname.endsWith(pathEndsWith)) {
|
|
175
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("pathEndsWith", { path: pathEndsWith }), path: [] }])
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Query validation
|
|
179
|
+
if (mustHaveQuery && !urlObj.search) {
|
|
180
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("hasQuery"), path: [] }])
|
|
181
|
+
}
|
|
182
|
+
if (mustNotHaveQuery && urlObj.search) {
|
|
183
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noQuery"), path: [] }])
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Fragment validation
|
|
187
|
+
if (mustHaveFragment && !urlObj.hash) {
|
|
188
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("hasFragment"), path: [] }])
|
|
189
|
+
}
|
|
190
|
+
if (mustNotHaveFragment && urlObj.hash) {
|
|
191
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noFragment"), path: [] }])
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Localhost validation
|
|
195
|
+
const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname.startsWith("192.168.") || hostname.startsWith("10.") || hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)
|
|
196
|
+
if (blockLocalhost && isLocalhost) {
|
|
197
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noLocalhost"), path: [] }])
|
|
198
|
+
}
|
|
199
|
+
if (!allowLocalhost && isLocalhost) {
|
|
200
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("localhost"), path: [] }])
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return true
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return schema as unknown as UrlSchema<IsRequired>
|
|
207
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
|
|
5
|
+
export type BusinessIdMessages = {
|
|
6
|
+
required?: string
|
|
7
|
+
invalid?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type BusinessIdOptions<IsRequired extends boolean = true> = {
|
|
11
|
+
required?: IsRequired
|
|
12
|
+
transform?: (value: string) => string
|
|
13
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
14
|
+
i18n?: Record<Locale, BusinessIdMessages>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type BusinessIdSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
18
|
+
|
|
19
|
+
// Taiwan Business ID (統一編號) validation
|
|
20
|
+
const validateTaiwanBusinessId = (value: string): boolean => {
|
|
21
|
+
// Must be exactly 8 digits
|
|
22
|
+
if (!/^\d{8}$/.test(value)) {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const digits = value.split('').map(Number)
|
|
27
|
+
|
|
28
|
+
// Coefficients for the first 7 digits
|
|
29
|
+
const coefficients = [1, 2, 1, 2, 1, 2, 4]
|
|
30
|
+
|
|
31
|
+
// Calculate weighted sum for first 7 digits
|
|
32
|
+
let sum = 0
|
|
33
|
+
for (let i = 0; i < 7; i++) {
|
|
34
|
+
const product = digits[i] * coefficients[i]
|
|
35
|
+
// Add individual digits of the product (split if >= 10)
|
|
36
|
+
sum += Math.floor(product / 10) + (product % 10)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Add the check digit (8th digit)
|
|
40
|
+
sum += digits[7]
|
|
41
|
+
|
|
42
|
+
// New rules (2023+): Valid if sum is divisible by 5
|
|
43
|
+
if (sum % 5 === 0) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fall back to old rules: Valid if sum is divisible by 10
|
|
48
|
+
if (sum % 10 === 0) {
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Special case for old rules: if 7th digit is 7
|
|
53
|
+
if (digits[6] === 7) {
|
|
54
|
+
let altSum = 0
|
|
55
|
+
for (let i = 0; i < 7; i++) {
|
|
56
|
+
const product = digits[i] * coefficients[i]
|
|
57
|
+
altSum += Math.floor(product / 10) + (product % 10)
|
|
58
|
+
}
|
|
59
|
+
// Add 1 and check digit
|
|
60
|
+
altSum += 1 + digits[7]
|
|
61
|
+
|
|
62
|
+
// Check both new and old rules
|
|
63
|
+
if (altSum % 5 === 0 || altSum % 10 === 0) {
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function businessId<IsRequired extends boolean = true>(options?: BusinessIdOptions<IsRequired>): BusinessIdSchema<IsRequired> {
|
|
72
|
+
const {
|
|
73
|
+
required = true,
|
|
74
|
+
transform,
|
|
75
|
+
defaultValue,
|
|
76
|
+
i18n
|
|
77
|
+
} = options ?? {}
|
|
78
|
+
|
|
79
|
+
// Set appropriate default value based on required flag
|
|
80
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
81
|
+
|
|
82
|
+
// Helper function to get custom message or fallback to default i18n
|
|
83
|
+
const getMessage = (key: keyof BusinessIdMessages, params?: Record<string, any>) => {
|
|
84
|
+
if (i18n) {
|
|
85
|
+
const currentLocale = getLocale()
|
|
86
|
+
const customMessages = i18n[currentLocale]
|
|
87
|
+
if (customMessages && customMessages[key]) {
|
|
88
|
+
const template = customMessages[key]!
|
|
89
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return t(`taiwan.businessId.${key}`, params)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Preprocessing function
|
|
96
|
+
const preprocessFn = (val: unknown) => {
|
|
97
|
+
if (val === "" || val === null || val === undefined) {
|
|
98
|
+
return actualDefaultValue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let processed = String(val).trim()
|
|
102
|
+
|
|
103
|
+
// If after trimming we have an empty string and the field is optional, return null
|
|
104
|
+
if (processed === "" && !required) {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (transform) {
|
|
109
|
+
processed = transform(processed)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return processed
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
116
|
+
|
|
117
|
+
const schema = baseSchema.refine((val) => {
|
|
118
|
+
if (val === null) return true
|
|
119
|
+
|
|
120
|
+
// Required check
|
|
121
|
+
if (required && (val === "" || val === "null" || val === "undefined")) {
|
|
122
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (val === null) return true
|
|
126
|
+
if (!required && val === "") return true
|
|
127
|
+
|
|
128
|
+
// Taiwan Business ID format validation (8 digits + checksum)
|
|
129
|
+
if (!validateTaiwanBusinessId(val)) {
|
|
130
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return true
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return schema as unknown as BusinessIdSchema<IsRequired>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Export utility function for external use
|
|
140
|
+
export { validateTaiwanBusinessId }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
|
|
5
|
+
export type FaxMessages = {
|
|
6
|
+
required?: string
|
|
7
|
+
invalid?: string
|
|
8
|
+
notInWhitelist?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type FaxOptions<IsRequired extends boolean = true> = {
|
|
12
|
+
required?: IsRequired
|
|
13
|
+
whitelist?: string[]
|
|
14
|
+
transform?: (value: string) => string
|
|
15
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
16
|
+
i18n?: Record<Locale, FaxMessages>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type FaxSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
20
|
+
|
|
21
|
+
// Taiwan fax number validation (Official 2024 rules - same as landline)
|
|
22
|
+
const validateTaiwanFax = (value: string): boolean => {
|
|
23
|
+
// Official Taiwan fax formats according to telecom numbering plan (same as landline):
|
|
24
|
+
// 02: Taipei, New Taipei, Keelung - 8 digits (2&3&5~8+7D)
|
|
25
|
+
// 03: Taoyuan, Hsinchu, Yilan, Hualien - 7 digits
|
|
26
|
+
// 037: Miaoli - 6 digits (2~9+5D)
|
|
27
|
+
// 04: Taichung, Changhua - 7 digits
|
|
28
|
+
// 049: Nantou - 7 digits (2~9+6D)
|
|
29
|
+
// 05: Yunlin, Chiayi - 7 digits
|
|
30
|
+
// 06: Tainan - 7 digits
|
|
31
|
+
// 07: Kaohsiung - 7 digits (2~9+6D)
|
|
32
|
+
// 08: Pingtung - 7 digits (4&7&8+6D)
|
|
33
|
+
// 082: Kinmen - 6 digits (2~5&7~9+5D)
|
|
34
|
+
// 0826: Wuqiu - 5 digits (6+4D)
|
|
35
|
+
// 0836: Matsu - 5 digits (2~9+4D)
|
|
36
|
+
// 089: Taitung - 6 digits (2~9+5D)
|
|
37
|
+
|
|
38
|
+
// Remove common separators for validation
|
|
39
|
+
const cleanValue = value.replace(/[-\s]/g, "")
|
|
40
|
+
|
|
41
|
+
// Basic format: starts with 0, then area code, then number
|
|
42
|
+
if (!/^0\d{7,10}$/.test(cleanValue)) {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check 4-digit area codes first
|
|
47
|
+
const areaCode4 = cleanValue.substring(0, 4)
|
|
48
|
+
if (areaCode4 === '0826') {
|
|
49
|
+
// Wuqiu: 0826 + 5 digits (6+4D), total 9 digits
|
|
50
|
+
return cleanValue.length === 9 && /^0826[6]\d{4}$/.test(cleanValue)
|
|
51
|
+
}
|
|
52
|
+
if (areaCode4 === '0836') {
|
|
53
|
+
// Matsu: 0836 + 5 digits (2~9+4D), total 9 digits
|
|
54
|
+
return cleanValue.length === 9 && /^0836[2-9]\d{4}$/.test(cleanValue)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check 3-digit area codes
|
|
58
|
+
const areaCode3 = cleanValue.substring(0, 3)
|
|
59
|
+
if (areaCode3 === '037') {
|
|
60
|
+
// Miaoli: 037 + 6 digits (2~9+5D), total 9 digits
|
|
61
|
+
return cleanValue.length === 9 && /^037[2-9]\d{5}$/.test(cleanValue)
|
|
62
|
+
}
|
|
63
|
+
if (areaCode3 === '049') {
|
|
64
|
+
// Nantou: 049 + 7 digits (2~9+6D), total 10 digits
|
|
65
|
+
return cleanValue.length === 10 && /^049[2-9]\d{6}$/.test(cleanValue)
|
|
66
|
+
}
|
|
67
|
+
if (areaCode3 === '082') {
|
|
68
|
+
// Kinmen: 082 + 6 digits (2~5&7~9+5D), total 9 digits
|
|
69
|
+
return cleanValue.length === 9 && /^082[2-57-9]\d{5}$/.test(cleanValue)
|
|
70
|
+
}
|
|
71
|
+
if (areaCode3 === '089') {
|
|
72
|
+
// Taitung: 089 + 6 digits (2~9+5D), total 9 digits
|
|
73
|
+
return cleanValue.length === 9 && /^089[2-9]\d{5}$/.test(cleanValue)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check 2-digit area codes
|
|
77
|
+
const areaCode2 = cleanValue.substring(0, 2)
|
|
78
|
+
|
|
79
|
+
if (areaCode2 === '02') {
|
|
80
|
+
// Taipei, New Taipei, Keelung: 02 + 8 digits (2&3&5~8+7D), total 10 digits
|
|
81
|
+
return cleanValue.length === 10 && /^02[235-8]\d{7}$/.test(cleanValue)
|
|
82
|
+
}
|
|
83
|
+
if (['03', '04', '05', '06'].includes(areaCode2)) {
|
|
84
|
+
// Taoyuan/Hsinchu/Yilan/Hualien (03), Taichung/Changhua (04),
|
|
85
|
+
// Yunlin/Chiayi (05), Tainan (06): 7 digits, total 9 digits
|
|
86
|
+
return cleanValue.length === 9
|
|
87
|
+
}
|
|
88
|
+
if (areaCode2 === '07') {
|
|
89
|
+
// Kaohsiung: 07 + 7 digits (2~9+6D), total 9 digits
|
|
90
|
+
return cleanValue.length === 9 && /^07[2-9]\d{6}$/.test(cleanValue)
|
|
91
|
+
}
|
|
92
|
+
if (areaCode2 === '08') {
|
|
93
|
+
// Pingtung: 08 + 7 digits (4&7&8+6D), total 9 digits
|
|
94
|
+
return cleanValue.length === 9 && /^08[478]\d{6}$/.test(cleanValue)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function fax<IsRequired extends boolean = true>(options?: FaxOptions<IsRequired>): FaxSchema<IsRequired> {
|
|
101
|
+
const { required = true, whitelist, transform, defaultValue, i18n } = options ?? {}
|
|
102
|
+
|
|
103
|
+
// Set appropriate default value based on required flag
|
|
104
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
105
|
+
|
|
106
|
+
// Helper function to get custom message or fallback to default i18n
|
|
107
|
+
const getMessage = (key: keyof FaxMessages, params?: Record<string, any>) => {
|
|
108
|
+
if (i18n) {
|
|
109
|
+
const currentLocale = getLocale()
|
|
110
|
+
const customMessages = i18n[currentLocale]
|
|
111
|
+
if (customMessages && customMessages[key]) {
|
|
112
|
+
const template = customMessages[key]!
|
|
113
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return t(`taiwan.fax.${key}`, params)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Preprocessing function
|
|
120
|
+
const preprocessFn = (val: unknown) => {
|
|
121
|
+
if (val === null || val === undefined) {
|
|
122
|
+
return actualDefaultValue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let processed = String(val).trim()
|
|
126
|
+
|
|
127
|
+
// If after trimming we have an empty string
|
|
128
|
+
if (processed === "") {
|
|
129
|
+
// If empty string is in allowlist, return it as is
|
|
130
|
+
if (whitelist && whitelist.includes("")) {
|
|
131
|
+
return ""
|
|
132
|
+
}
|
|
133
|
+
// If the field is optional and empty string not in allowlist, return default value
|
|
134
|
+
if (!required) {
|
|
135
|
+
return actualDefaultValue
|
|
136
|
+
}
|
|
137
|
+
// If a field is required, return the default value (will be validated later)
|
|
138
|
+
return actualDefaultValue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (transform) {
|
|
142
|
+
processed = transform(processed)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return processed
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
149
|
+
|
|
150
|
+
const schema = baseSchema.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
|
+
if (val === null) return true
|
|
159
|
+
if (!required && val === "") return true
|
|
160
|
+
|
|
161
|
+
// Allowlist check (if an allowlist is provided, only allow values in the allowlist)
|
|
162
|
+
if (whitelist && whitelist.length > 0) {
|
|
163
|
+
if (whitelist.includes(val)) {
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
// If not in the allowlist, reject regardless of format
|
|
167
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("notInWhitelist"), path: [] }])
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Taiwan fax format validation (only if no allowlist or allowlist is empty)
|
|
171
|
+
if (!validateTaiwanFax(val)) {
|
|
172
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return true
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
return schema as unknown as FaxSchema<IsRequired>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Export utility function for external use
|
|
182
|
+
export { validateTaiwanFax }
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
|
|
5
|
+
export type MobileMessages = {
|
|
6
|
+
required?: string
|
|
7
|
+
invalid?: string
|
|
8
|
+
notInWhitelist?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type MobileOptions<IsRequired extends boolean = true> = {
|
|
12
|
+
required?: IsRequired
|
|
13
|
+
whitelist?: string[]
|
|
14
|
+
transform?: (value: string) => string
|
|
15
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
16
|
+
i18n?: Record<Locale, MobileMessages>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type MobileSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
20
|
+
|
|
21
|
+
// Taiwan mobile phone validation
|
|
22
|
+
const validateTaiwanMobile = (value: string): boolean => {
|
|
23
|
+
// Taiwan mobile phone format: 09 + 8 digits
|
|
24
|
+
// Valid prefixes: 090, 091, 092, 093, 094, 095, 096, 097, 098, 099
|
|
25
|
+
return /^09[0-9]\d{7}$/.test(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function mobile<IsRequired extends boolean = true>(options?: MobileOptions<IsRequired>): MobileSchema<IsRequired> {
|
|
29
|
+
const { required = true, whitelist, transform, defaultValue, i18n } = options ?? {}
|
|
30
|
+
|
|
31
|
+
// Set appropriate default value based on required flag
|
|
32
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
33
|
+
|
|
34
|
+
// Helper function to get custom message or fallback to default i18n
|
|
35
|
+
const getMessage = (key: keyof MobileMessages, params?: Record<string, any>) => {
|
|
36
|
+
if (i18n) {
|
|
37
|
+
const currentLocale = getLocale()
|
|
38
|
+
const customMessages = i18n[currentLocale]
|
|
39
|
+
if (customMessages && customMessages[key]) {
|
|
40
|
+
const template = customMessages[key]!
|
|
41
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return t(`taiwan.mobile.${key}`, params)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Preprocessing function
|
|
48
|
+
const preprocessFn = (val: unknown) => {
|
|
49
|
+
if (val === null || val === undefined) {
|
|
50
|
+
return actualDefaultValue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let processed = String(val).trim()
|
|
54
|
+
|
|
55
|
+
// If after trimming we have an empty string
|
|
56
|
+
if (processed === "") {
|
|
57
|
+
// If empty string is in allowlist, return it as is
|
|
58
|
+
if (whitelist && whitelist.includes("")) {
|
|
59
|
+
return ""
|
|
60
|
+
}
|
|
61
|
+
// If the field is optional and empty string not in allowlist, return default value
|
|
62
|
+
if (!required) {
|
|
63
|
+
return actualDefaultValue
|
|
64
|
+
}
|
|
65
|
+
// If a field is required, return the default value (will be validated later)
|
|
66
|
+
return actualDefaultValue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (transform) {
|
|
70
|
+
processed = transform(processed)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return processed
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
77
|
+
|
|
78
|
+
const schema = baseSchema.refine((val) => {
|
|
79
|
+
if (val === null) return true
|
|
80
|
+
|
|
81
|
+
// Required check
|
|
82
|
+
if (required && (val === "" || val === "null" || val === "undefined")) {
|
|
83
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (val === null) return true
|
|
87
|
+
if (!required && val === "") return true
|
|
88
|
+
|
|
89
|
+
// Allowlist check (if an allowlist is provided, only allow values in the allowlist)
|
|
90
|
+
if (whitelist && whitelist.length > 0) {
|
|
91
|
+
if (whitelist.includes(val)) {
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
// If not in the allowlist, reject regardless of format
|
|
95
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("notInWhitelist"), path: [] }])
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Taiwan mobile phone format validation (only if no allowlist or allowlist is empty)
|
|
99
|
+
if (!validateTaiwanMobile(val)) {
|
|
100
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return true
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
return schema as unknown as MobileSchema<IsRequired>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Export utility function for external use
|
|
110
|
+
export { validateTaiwanMobile }
|