@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.
Files changed (51) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +5 -5
  4. package/debug.js +21 -0
  5. package/debug.ts +16 -0
  6. package/dist/index.cjs +1619 -145
  7. package/dist/index.d.cts +324 -25
  8. package/dist/index.d.ts +324 -25
  9. package/dist/index.js +1590 -143
  10. package/eslint.config.mts +8 -0
  11. package/package.json +10 -9
  12. package/src/config.ts +1 -1
  13. package/src/i18n/locales/en.json +99 -25
  14. package/src/i18n/locales/zh-TW.json +103 -26
  15. package/src/index.ts +13 -7
  16. package/src/validators/common/boolean.ts +97 -0
  17. package/src/validators/common/date.ts +171 -0
  18. package/src/validators/common/email.ts +200 -0
  19. package/src/validators/common/id.ts +259 -0
  20. package/src/validators/common/number.ts +194 -0
  21. package/src/validators/common/password.ts +214 -0
  22. package/src/validators/common/text.ts +151 -0
  23. package/src/validators/common/url.ts +207 -0
  24. package/src/validators/taiwan/business-id.ts +140 -0
  25. package/src/validators/taiwan/fax.ts +182 -0
  26. package/src/validators/taiwan/mobile.ts +110 -0
  27. package/src/validators/taiwan/national-id.ts +208 -0
  28. package/src/validators/taiwan/tel.ts +182 -0
  29. package/tests/common/boolean.test.ts +340 -92
  30. package/tests/common/date.test.ts +458 -0
  31. package/tests/common/email.test.ts +232 -60
  32. package/tests/common/id.test.ts +535 -0
  33. package/tests/common/number.test.ts +230 -60
  34. package/tests/common/password.test.ts +271 -44
  35. package/tests/common/text.test.ts +210 -13
  36. package/tests/common/url.test.ts +492 -67
  37. package/tests/taiwan/business-id.test.ts +240 -0
  38. package/tests/taiwan/fax.test.ts +463 -0
  39. package/tests/taiwan/mobile.test.ts +373 -0
  40. package/tests/taiwan/national-id.test.ts +435 -0
  41. package/tests/taiwan/tel.test.ts +467 -0
  42. package/eslint.config.mjs +0 -10
  43. package/src/common/boolean.ts +0 -36
  44. package/src/common/date.ts +0 -43
  45. package/src/common/email.ts +0 -44
  46. package/src/common/integer.ts +0 -46
  47. package/src/common/number.ts +0 -37
  48. package/src/common/password.ts +0 -33
  49. package/src/common/text.ts +0 -34
  50. package/src/common/url.ts +0 -37
  51. 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 }