@hy_ong/zod-kit 0.2.0 → 0.2.1

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 (189) hide show
  1. package/.github/workflows/ci.yml +24 -0
  2. package/CLAUDE.md +64 -22
  3. package/dist/chunk-36NWHESN.js +124 -0
  4. package/dist/chunk-4LYZAO3P.js +165 -0
  5. package/dist/chunk-5GAZQDVS.cjs +206 -0
  6. package/dist/chunk-5LS4DSRQ.cjs +127 -0
  7. package/dist/chunk-5OGW2ERW.js +181 -0
  8. package/dist/chunk-5ZEKWPSE.cjs +69 -0
  9. package/dist/chunk-6OGDPSWT.js +135 -0
  10. package/dist/chunk-6X22I6NQ.cjs +136 -0
  11. package/dist/chunk-77KZUPPN.cjs +177 -0
  12. package/dist/chunk-AANSHH2O.cjs +165 -0
  13. package/dist/chunk-AI72FMOF.cjs +130 -0
  14. package/dist/chunk-AWV2IT66.js +146 -0
  15. package/dist/chunk-B3U5G3AA.js +160 -0
  16. package/dist/chunk-CFFCBWYL.cjs +99 -0
  17. package/dist/chunk-DPXRMSB2.js +130 -0
  18. package/dist/chunk-DRXPGQM6.cjs +135 -0
  19. package/dist/chunk-EAU42EVH.js +161 -0
  20. package/dist/chunk-FC6VDOC7.js +206 -0
  21. package/dist/chunk-FVO4743A.cjs +134 -0
  22. package/dist/chunk-G6DV7LX7.cjs +161 -0
  23. package/dist/chunk-I2RJMDXN.js +90 -0
  24. package/dist/chunk-IJEEM3DI.js +136 -0
  25. package/dist/chunk-JBNCMS42.cjs +151 -0
  26. package/dist/chunk-JZ2SHRGZ.js +87 -0
  27. package/dist/chunk-KARFFIMP.js +696 -0
  28. package/dist/chunk-LIQSVJLS.js +177 -0
  29. package/dist/chunk-LKPXHW5N.cjs +181 -0
  30. package/dist/chunk-MAQRXYE6.js +118 -0
  31. package/dist/chunk-MCDESS3T.js +69 -0
  32. package/dist/chunk-MG25BEV4.cjs +160 -0
  33. package/dist/chunk-NKCYXBGX.js +99 -0
  34. package/dist/chunk-OEK7QSQP.js +75 -0
  35. package/dist/chunk-OMFQ7Z63.cjs +696 -0
  36. package/dist/chunk-OP4KV3BY.cjs +124 -0
  37. package/dist/chunk-P2NONIMS.js +257 -0
  38. package/dist/chunk-P364KRO5.js +61 -0
  39. package/dist/chunk-PGSDXR2I.js +71 -0
  40. package/dist/chunk-PL2GERLG.cjs +61 -0
  41. package/dist/chunk-R5G4V7C6.cjs +75 -0
  42. package/dist/chunk-RKHX3DGH.js +127 -0
  43. package/dist/chunk-TSHL7ZO2.js +134 -0
  44. package/dist/chunk-UFNVCUPQ.cjs +301 -0
  45. package/dist/chunk-VCRKYMJM.js +301 -0
  46. package/dist/chunk-VDOAPLA6.cjs +257 -0
  47. package/dist/chunk-VP5CCP5F.cjs +90 -0
  48. package/dist/chunk-W2EWMV3A.cjs +87 -0
  49. package/dist/chunk-WWRFBLCR.cjs +146 -0
  50. package/dist/chunk-YALLOVNO.cjs +118 -0
  51. package/dist/chunk-YAU6JCYL.cjs +71 -0
  52. package/dist/chunk-YWV2BBXN.cjs +2526 -0
  53. package/dist/chunk-ZBOQCXD4.js +2526 -0
  54. package/dist/chunk-ZFQQXWNB.js +151 -0
  55. package/dist/common/boolean.cjs +7 -0
  56. package/dist/common/boolean.d.cts +119 -0
  57. package/dist/common/boolean.d.ts +119 -0
  58. package/dist/common/boolean.js +7 -0
  59. package/dist/common/color.cjs +9 -0
  60. package/dist/common/color.d.cts +26 -0
  61. package/dist/common/color.d.ts +26 -0
  62. package/dist/common/color.js +9 -0
  63. package/dist/common/coordinate.cjs +11 -0
  64. package/dist/common/coordinate.d.cts +23 -0
  65. package/dist/common/coordinate.d.ts +23 -0
  66. package/dist/common/coordinate.js +11 -0
  67. package/dist/common/credit-card.cjs +11 -0
  68. package/dist/common/credit-card.d.cts +22 -0
  69. package/dist/common/credit-card.d.ts +22 -0
  70. package/dist/common/credit-card.js +11 -0
  71. package/dist/common/date.cjs +7 -0
  72. package/dist/common/date.d.cts +174 -0
  73. package/dist/common/date.d.ts +174 -0
  74. package/dist/common/date.js +7 -0
  75. package/dist/common/datetime.cjs +15 -0
  76. package/dist/common/datetime.d.cts +301 -0
  77. package/dist/common/datetime.d.ts +301 -0
  78. package/dist/common/datetime.js +15 -0
  79. package/dist/common/email.cjs +7 -0
  80. package/dist/common/email.d.cts +149 -0
  81. package/dist/common/email.d.ts +149 -0
  82. package/dist/common/email.js +7 -0
  83. package/dist/common/file.cjs +7 -0
  84. package/dist/common/file.d.cts +178 -0
  85. package/dist/common/file.d.ts +178 -0
  86. package/dist/common/file.js +7 -0
  87. package/dist/common/id.cjs +13 -0
  88. package/dist/common/id.d.cts +262 -0
  89. package/dist/common/id.d.ts +262 -0
  90. package/dist/common/id.js +13 -0
  91. package/dist/common/ip.cjs +11 -0
  92. package/dist/common/ip.d.cts +25 -0
  93. package/dist/common/ip.d.ts +25 -0
  94. package/dist/common/ip.js +11 -0
  95. package/dist/common/number.cjs +7 -0
  96. package/dist/common/number.d.cts +167 -0
  97. package/dist/common/number.d.ts +167 -0
  98. package/dist/common/number.js +7 -0
  99. package/dist/common/password.cjs +7 -0
  100. package/dist/common/password.d.cts +192 -0
  101. package/dist/common/password.d.ts +192 -0
  102. package/dist/common/password.js +7 -0
  103. package/dist/common/text.cjs +7 -0
  104. package/dist/common/text.d.cts +156 -0
  105. package/dist/common/text.d.ts +156 -0
  106. package/dist/common/text.js +7 -0
  107. package/dist/common/time.cjs +15 -0
  108. package/dist/common/time.d.cts +268 -0
  109. package/dist/common/time.d.ts +268 -0
  110. package/dist/common/time.js +15 -0
  111. package/dist/common/url.cjs +7 -0
  112. package/dist/common/url.d.cts +196 -0
  113. package/dist/common/url.d.ts +196 -0
  114. package/dist/common/url.js +7 -0
  115. package/dist/config-CABSSvAp.d.cts +5 -0
  116. package/dist/config-CABSSvAp.d.ts +5 -0
  117. package/dist/index.cjs +180 -5255
  118. package/dist/index.d.cts +28 -3150
  119. package/dist/index.d.ts +28 -3150
  120. package/dist/index.js +135 -5131
  121. package/dist/taiwan/bank-account.cjs +11 -0
  122. package/dist/taiwan/bank-account.d.cts +22 -0
  123. package/dist/taiwan/bank-account.d.ts +22 -0
  124. package/dist/taiwan/bank-account.js +11 -0
  125. package/dist/taiwan/business-id.cjs +9 -0
  126. package/dist/taiwan/business-id.d.cts +133 -0
  127. package/dist/taiwan/business-id.d.ts +133 -0
  128. package/dist/taiwan/business-id.js +9 -0
  129. package/dist/taiwan/fax.cjs +9 -0
  130. package/dist/taiwan/fax.d.cts +157 -0
  131. package/dist/taiwan/fax.d.ts +157 -0
  132. package/dist/taiwan/fax.js +9 -0
  133. package/dist/taiwan/invoice.cjs +9 -0
  134. package/dist/taiwan/invoice.d.cts +17 -0
  135. package/dist/taiwan/invoice.d.ts +17 -0
  136. package/dist/taiwan/invoice.js +9 -0
  137. package/dist/taiwan/license-plate.cjs +9 -0
  138. package/dist/taiwan/license-plate.d.cts +19 -0
  139. package/dist/taiwan/license-plate.d.ts +19 -0
  140. package/dist/taiwan/license-plate.js +9 -0
  141. package/dist/taiwan/mobile.cjs +9 -0
  142. package/dist/taiwan/mobile.d.cts +146 -0
  143. package/dist/taiwan/mobile.d.ts +146 -0
  144. package/dist/taiwan/mobile.js +9 -0
  145. package/dist/taiwan/national-id.cjs +15 -0
  146. package/dist/taiwan/national-id.d.cts +214 -0
  147. package/dist/taiwan/national-id.d.ts +214 -0
  148. package/dist/taiwan/national-id.js +15 -0
  149. package/dist/taiwan/passport.cjs +9 -0
  150. package/dist/taiwan/passport.d.cts +19 -0
  151. package/dist/taiwan/passport.d.ts +19 -0
  152. package/dist/taiwan/passport.js +9 -0
  153. package/dist/taiwan/postal-code.cjs +17 -0
  154. package/dist/taiwan/postal-code.d.cts +237 -0
  155. package/dist/taiwan/postal-code.d.ts +237 -0
  156. package/dist/taiwan/postal-code.js +17 -0
  157. package/dist/taiwan/tel.cjs +9 -0
  158. package/dist/taiwan/tel.d.cts +162 -0
  159. package/dist/taiwan/tel.d.ts +162 -0
  160. package/dist/taiwan/tel.js +9 -0
  161. package/package.json +128 -2
  162. package/src/i18n/locales/en-GB.json +43 -0
  163. package/src/i18n/locales/en-US.json +43 -0
  164. package/src/i18n/locales/id-ID.json +43 -0
  165. package/src/i18n/locales/ja-JP.json +43 -0
  166. package/src/i18n/locales/ko-KR.json +43 -0
  167. package/src/i18n/locales/ms-MY.json +43 -0
  168. package/src/i18n/locales/th-TH.json +43 -0
  169. package/src/i18n/locales/vi-VN.json +43 -0
  170. package/src/i18n/locales/zh-CN.json +43 -0
  171. package/src/i18n/locales/zh-TW.json +43 -0
  172. package/src/index.ts +10 -2
  173. package/src/validators/common/color.ts +192 -0
  174. package/src/validators/common/coordinate.ts +159 -0
  175. package/src/validators/common/credit-card.ts +134 -0
  176. package/src/validators/common/ip.ts +210 -0
  177. package/src/validators/taiwan/bank-account.ts +176 -0
  178. package/src/validators/taiwan/invoice.ts +84 -0
  179. package/src/validators/taiwan/license-plate.ts +110 -0
  180. package/src/validators/taiwan/passport.ts +103 -0
  181. package/tests/common/color.test.ts +587 -0
  182. package/tests/common/coordinate.test.ts +345 -0
  183. package/tests/common/credit-card.test.ts +378 -0
  184. package/tests/common/ip.test.ts +419 -0
  185. package/tests/taiwan/bank-account.test.ts +286 -0
  186. package/tests/taiwan/invoice.test.ts +227 -0
  187. package/tests/taiwan/license-plate.test.ts +280 -0
  188. package/tests/taiwan/passport.test.ts +277 -0
  189. package/tsup.config.ts +36 -0
@@ -0,0 +1,210 @@
1
+ import { z, ZodNullable, ZodString } from "zod"
2
+ import { t } from "../../i18n"
3
+ import { getLocale, type Locale } from "../../config"
4
+
5
+ export type IpVersion = "v4" | "v6" | "any"
6
+
7
+ export type IpMessages = {
8
+ required?: string
9
+ invalid?: string
10
+ notIPv4?: string
11
+ notIPv6?: string
12
+ notInWhitelist?: string
13
+ }
14
+
15
+ export type IpOptions<IsRequired extends boolean = true> = {
16
+ version?: IpVersion
17
+ allowCIDR?: boolean
18
+ whitelist?: string[]
19
+ transform?: (value: string) => string
20
+ defaultValue?: IsRequired extends true ? string : string | null
21
+ i18n?: Partial<Record<Locale, Partial<IpMessages>>>
22
+ }
23
+
24
+ export type IpSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
25
+
26
+ export function validateIPv4(value: string): boolean {
27
+ const parts = value.split(".")
28
+ if (parts.length !== 4) return false
29
+
30
+ for (const part of parts) {
31
+ if (part === "") return false
32
+ // No leading zeros except for the single digit "0"
33
+ if (part.length > 1 && part.startsWith("0")) return false
34
+ const num = Number(part)
35
+ if (!Number.isInteger(num) || num < 0 || num > 255) return false
36
+ }
37
+
38
+ return true
39
+ }
40
+
41
+ export function validateIPv6(value: string): boolean {
42
+ // Handle mixed IPv6/IPv4 (e.g., ::ffff:192.0.2.1)
43
+ const lastColon = value.lastIndexOf(":")
44
+ if (lastColon !== -1) {
45
+ const afterLastColon = value.substring(lastColon + 1)
46
+ if (afterLastColon.includes(".")) {
47
+ // Mixed form: validate the IPv4 portion
48
+ if (!validateIPv4(afterLastColon)) return false
49
+
50
+ // Validate the IPv6 prefix portion (everything before the IPv4 part)
51
+ const ipv6Prefix = value.substring(0, lastColon)
52
+ // The prefix should behave like an IPv6 with fewer groups (max 6 groups since IPv4 occupies 2)
53
+ return validateIPv6Groups(ipv6Prefix, 6)
54
+ }
55
+ }
56
+
57
+ return validateIPv6Groups(value, 8)
58
+ }
59
+
60
+ function validateIPv6Groups(value: string, maxGroups: number): boolean {
61
+ // Handle :: compression
62
+ if (value.includes("::")) {
63
+ // Only one :: is allowed
64
+ const doubleColonCount = value.split("::").length - 1
65
+ if (doubleColonCount > 1) return false
66
+
67
+ const [left, right] = value.split("::")
68
+ const leftGroups = left === "" ? [] : left.split(":")
69
+ const rightGroups = right === "" ? [] : right.split(":")
70
+
71
+ // Total groups when expanded must not exceed maxGroups
72
+ if (leftGroups.length + rightGroups.length >= maxGroups) return false
73
+
74
+ // Validate each group
75
+ for (const group of [...leftGroups, ...rightGroups]) {
76
+ if (!isValidHexGroup(group)) return false
77
+ }
78
+
79
+ return true
80
+ }
81
+
82
+ // Full form: must have exactly maxGroups groups
83
+ const groups = value.split(":")
84
+ if (groups.length !== maxGroups) return false
85
+
86
+ for (const group of groups) {
87
+ if (!isValidHexGroup(group)) return false
88
+ }
89
+
90
+ return true
91
+ }
92
+
93
+ function isValidHexGroup(group: string): boolean {
94
+ if (group.length === 0 || group.length > 4) return false
95
+ return /^[0-9a-fA-F]{1,4}$/.test(group)
96
+ }
97
+
98
+ export function ip<IsRequired extends boolean = false>(required?: IsRequired, options?: Omit<IpOptions<IsRequired>, "required">): IpSchema<IsRequired> {
99
+ const { version = "any", allowCIDR = false, whitelist, transform, defaultValue, i18n } = options ?? {}
100
+
101
+ const isRequired = required ?? (false as IsRequired)
102
+
103
+ const actualDefaultValue = defaultValue ?? (isRequired ? "" : null)
104
+
105
+ const getMessage = (key: keyof IpMessages, params?: Record<string, any>) => {
106
+ if (i18n) {
107
+ const currentLocale = getLocale()
108
+ const customMessages = i18n[currentLocale]
109
+ if (customMessages && customMessages[key]) {
110
+ const template = customMessages[key]!
111
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
112
+ }
113
+ }
114
+ return t(`common.ip.${key}`, params)
115
+ }
116
+
117
+ const preprocessFn = (val: unknown) => {
118
+ if (val === "" || val === null || val === undefined) {
119
+ return actualDefaultValue
120
+ }
121
+
122
+ let processed = String(val).trim()
123
+
124
+ if (processed === "" && !required) {
125
+ return null
126
+ }
127
+
128
+ if (transform) {
129
+ processed = transform(processed)
130
+ }
131
+
132
+ return processed
133
+ }
134
+
135
+ const baseSchema = isRequired ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
136
+
137
+ const schema = baseSchema.superRefine((val, ctx) => {
138
+ if (val === null) return
139
+
140
+ // Required check
141
+ if (isRequired && (val === "" || val === "null" || val === "undefined")) {
142
+ ctx.addIssue({ code: "custom", message: getMessage("required") })
143
+ return
144
+ }
145
+
146
+ if (!isRequired && val === "") return
147
+
148
+ // Separate CIDR prefix if present
149
+ let ipPart = val
150
+ let cidrPrefix: string | null = null
151
+
152
+ const slashIndex = val.indexOf("/")
153
+ if (slashIndex !== -1) {
154
+ if (!allowCIDR) {
155
+ ctx.addIssue({ code: "custom", message: getMessage("invalid") })
156
+ return
157
+ }
158
+ ipPart = val.substring(0, slashIndex)
159
+ cidrPrefix = val.substring(slashIndex + 1)
160
+ }
161
+
162
+ // Determine which version(s) to try
163
+ const isV4 = validateIPv4(ipPart)
164
+ const isV6 = validateIPv6(ipPart)
165
+
166
+ if (version === "v4") {
167
+ if (!isV4) {
168
+ ctx.addIssue({ code: "custom", message: getMessage("notIPv4") })
169
+ return
170
+ }
171
+ } else if (version === "v6") {
172
+ if (!isV6) {
173
+ ctx.addIssue({ code: "custom", message: getMessage("notIPv6") })
174
+ return
175
+ }
176
+ } else {
177
+ // version === "any"
178
+ if (!isV4 && !isV6) {
179
+ ctx.addIssue({ code: "custom", message: getMessage("invalid") })
180
+ return
181
+ }
182
+ }
183
+
184
+ // Validate CIDR prefix length
185
+ if (cidrPrefix !== null) {
186
+ if (!/^\d+$/.test(cidrPrefix)) {
187
+ ctx.addIssue({ code: "custom", message: getMessage("invalid") })
188
+ return
189
+ }
190
+
191
+ const prefixNum = Number(cidrPrefix)
192
+ const maxPrefix = isV4 ? 32 : 128
193
+
194
+ if (prefixNum < 0 || prefixNum > maxPrefix) {
195
+ ctx.addIssue({ code: "custom", message: getMessage("invalid") })
196
+ return
197
+ }
198
+ }
199
+
200
+ // Whitelist check
201
+ if (whitelist && whitelist.length > 0) {
202
+ if (!whitelist.includes(val)) {
203
+ ctx.addIssue({ code: "custom", message: getMessage("notInWhitelist") })
204
+ return
205
+ }
206
+ }
207
+ })
208
+
209
+ return schema as unknown as IpSchema<IsRequired>
210
+ }
@@ -0,0 +1,176 @@
1
+ import { z, ZodNullable, ZodString } from "zod"
2
+ import { t } from "../../i18n"
3
+ import { getLocale, type Locale } from "../../config"
4
+
5
+ export const TAIWAN_BANK_CODES: Record<string, string> = {
6
+ "004": "台灣銀行",
7
+ "005": "土地銀行",
8
+ "006": "合庫",
9
+ "007": "第一銀行",
10
+ "008": "華南",
11
+ "009": "彰化",
12
+ "011": "上海",
13
+ "012": "台北富邦",
14
+ "013": "國泰世華",
15
+ "017": "兆豐",
16
+ "021": "花旗",
17
+ "048": "王道",
18
+ "050": "台灣企銀",
19
+ "052": "渣打",
20
+ "053": "台中銀行",
21
+ "054": "京城",
22
+ "081": "滙豐",
23
+ "103": "新光",
24
+ "108": "陽信",
25
+ "118": "板信",
26
+ "147": "三信",
27
+ "700": "中華郵政",
28
+ "803": "聯邦",
29
+ "805": "遠東",
30
+ "806": "元大",
31
+ "807": "永豐",
32
+ "808": "玉山",
33
+ "809": "凱基",
34
+ "810": "星展",
35
+ "812": "台新",
36
+ "816": "安泰",
37
+ "822": "中信",
38
+ }
39
+
40
+ export type TwBankAccountMessages = {
41
+ required?: string
42
+ invalid?: string
43
+ invalidBankCode?: string
44
+ invalidAccountNumber?: string
45
+ }
46
+
47
+ export type TwBankAccountOptions<IsRequired extends boolean = true> = {
48
+ validateBankCode?: boolean
49
+ bankCode?: string
50
+ transform?: (value: string) => string
51
+ defaultValue?: IsRequired extends true ? string : string | null
52
+ i18n?: Partial<Record<Locale, Partial<TwBankAccountMessages>>>
53
+ }
54
+
55
+ export type TwBankAccountSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
56
+
57
+ const validateTaiwanBankAccount = (value: string, validateBankCode: boolean = true): boolean => {
58
+ let bankCode: string | undefined
59
+ let accountNumber: string
60
+
61
+ if (value.includes("-")) {
62
+ const parts = value.split("-")
63
+ if (parts.length !== 2) return false
64
+ bankCode = parts[0]
65
+ accountNumber = parts[1]
66
+ } else {
67
+ accountNumber = value
68
+ }
69
+
70
+ // Validate bank code if present
71
+ if (bankCode !== undefined) {
72
+ if (!/^\d{3}$/.test(bankCode)) return false
73
+ if (validateBankCode && !(bankCode in TAIWAN_BANK_CODES)) return false
74
+ }
75
+
76
+ // Validate account number: 10-16 digits
77
+ if (!/^\d{10,16}$/.test(accountNumber)) return false
78
+
79
+ return true
80
+ }
81
+
82
+ export function twBankAccount<IsRequired extends boolean = false>(required?: IsRequired, options?: Omit<TwBankAccountOptions<IsRequired>, "required">): TwBankAccountSchema<IsRequired> {
83
+ const { validateBankCode = true, bankCode, transform, defaultValue, i18n } = options ?? {}
84
+
85
+ const isRequired = required ?? (false as IsRequired)
86
+
87
+ const actualDefaultValue = defaultValue ?? (isRequired ? "" : null)
88
+
89
+ const getMessage = (key: keyof TwBankAccountMessages, params?: Record<string, any>) => {
90
+ if (i18n) {
91
+ const currentLocale = getLocale()
92
+ const customMessages = i18n[currentLocale]
93
+ if (customMessages && customMessages[key]) {
94
+ const template = customMessages[key]!
95
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
96
+ }
97
+ }
98
+ return t(`taiwan.bankAccount.${key}`, params)
99
+ }
100
+
101
+ const preprocessFn = (val: unknown) => {
102
+ if (val === "" || val === null || val === undefined) {
103
+ return actualDefaultValue
104
+ }
105
+
106
+ let processed = String(val).trim().replace(/\s/g, "")
107
+
108
+ if (processed === "" && !required) {
109
+ return null
110
+ }
111
+
112
+ if (transform) {
113
+ processed = transform(processed)
114
+ }
115
+
116
+ // If a bankCode option is set and the value has no hyphen, prepend bankCode
117
+ if (bankCode && !processed.includes("-")) {
118
+ processed = `${bankCode}-${processed}`
119
+ }
120
+
121
+ return processed
122
+ }
123
+
124
+ const baseSchema = isRequired ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
125
+
126
+ const schema = baseSchema.superRefine((val, ctx) => {
127
+ if (val === null) return
128
+
129
+ // Required check
130
+ if (isRequired && (val === "" || val === "null" || val === "undefined")) {
131
+ ctx.addIssue({ code: "custom", message: getMessage("required") })
132
+ return
133
+ }
134
+
135
+ if (val === null) return
136
+ if (!isRequired && val === "") return
137
+
138
+ // Parse and validate parts separately for specific error messages
139
+ let parsedBankCode: string | undefined
140
+ let accountNumber: string
141
+
142
+ if (val.includes("-")) {
143
+ const parts = val.split("-")
144
+ if (parts.length !== 2) {
145
+ ctx.addIssue({ code: "custom", message: getMessage("invalid") })
146
+ return
147
+ }
148
+ parsedBankCode = parts[0]
149
+ accountNumber = parts[1]
150
+ } else {
151
+ accountNumber = val
152
+ }
153
+
154
+ // Validate bank code if present
155
+ if (parsedBankCode !== undefined) {
156
+ if (!/^\d{3}$/.test(parsedBankCode)) {
157
+ ctx.addIssue({ code: "custom", message: getMessage("invalidBankCode") })
158
+ return
159
+ }
160
+ if (validateBankCode && !(parsedBankCode in TAIWAN_BANK_CODES)) {
161
+ ctx.addIssue({ code: "custom", message: getMessage("invalidBankCode") })
162
+ return
163
+ }
164
+ }
165
+
166
+ // Validate account number: 10-16 digits
167
+ if (!/^\d{10,16}$/.test(accountNumber)) {
168
+ ctx.addIssue({ code: "custom", message: getMessage("invalidAccountNumber") })
169
+ return
170
+ }
171
+ })
172
+
173
+ return schema as unknown as TwBankAccountSchema<IsRequired>
174
+ }
175
+
176
+ export { validateTaiwanBankAccount }
@@ -0,0 +1,84 @@
1
+ import { z, ZodNullable, ZodString } from "zod"
2
+ import { t } from "../../i18n"
3
+ import { getLocale, type Locale } from "../../config"
4
+
5
+ export type TwInvoiceMessages = {
6
+ required?: string
7
+ invalid?: string
8
+ }
9
+
10
+ export type TwInvoiceOptions<IsRequired extends boolean = true> = {
11
+ transform?: (value: string) => string
12
+ defaultValue?: IsRequired extends true ? string : string | null
13
+ i18n?: Partial<Record<Locale, Partial<TwInvoiceMessages>>>
14
+ }
15
+
16
+ export type TwInvoiceSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
17
+
18
+ const INVOICE_PATTERN = /^[A-Z]{2}\d{8}$/
19
+
20
+ const validateTaiwanInvoice = (value: string): boolean => {
21
+ const cleaned = value.replace(/-/g, "")
22
+ return INVOICE_PATTERN.test(cleaned)
23
+ }
24
+
25
+ export function twInvoice<IsRequired extends boolean = false>(required?: IsRequired, options?: Omit<TwInvoiceOptions<IsRequired>, "required">): TwInvoiceSchema<IsRequired> {
26
+ const { transform, defaultValue, i18n } = options ?? {}
27
+
28
+ const isRequired = required ?? (false as IsRequired)
29
+
30
+ const actualDefaultValue = defaultValue ?? (isRequired ? "" : null)
31
+
32
+ const getMessage = (key: keyof TwInvoiceMessages, params?: Record<string, any>) => {
33
+ if (i18n) {
34
+ const currentLocale = getLocale()
35
+ const customMessages = i18n[currentLocale]
36
+ if (customMessages && customMessages[key]) {
37
+ const template = customMessages[key]!
38
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
39
+ }
40
+ }
41
+ return t(`taiwan.invoice.${key}`, params)
42
+ }
43
+
44
+ const preprocessFn = (val: unknown) => {
45
+ if (val === "" || val === null || val === undefined) {
46
+ return actualDefaultValue
47
+ }
48
+
49
+ let processed = String(val).trim().toUpperCase().replace(/-/g, "")
50
+
51
+ if (processed === "" && !required) {
52
+ return null
53
+ }
54
+
55
+ if (transform) {
56
+ processed = transform(processed)
57
+ }
58
+
59
+ return processed
60
+ }
61
+
62
+ const baseSchema = isRequired ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
63
+
64
+ const schema = baseSchema.superRefine((val, ctx) => {
65
+ if (val === null) return
66
+
67
+ if (isRequired && (val === "" || val === "null" || val === "undefined")) {
68
+ ctx.addIssue({ code: "custom", message: getMessage("required") })
69
+ return
70
+ }
71
+
72
+ if (val === null) return
73
+ if (!isRequired && val === "") return
74
+
75
+ if (!validateTaiwanInvoice(val)) {
76
+ ctx.addIssue({ code: "custom", message: getMessage("invalid") })
77
+ return
78
+ }
79
+ })
80
+
81
+ return schema as unknown as TwInvoiceSchema<IsRequired>
82
+ }
83
+
84
+ export { validateTaiwanInvoice }
@@ -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 PlateType = "car" | "motorcycle" | "any"
6
+
7
+ export type TwLicensePlateMessages = {
8
+ required?: string
9
+ invalid?: string
10
+ }
11
+
12
+ export type TwLicensePlateOptions<IsRequired extends boolean = true> = {
13
+ plateType?: PlateType
14
+ transform?: (value: string) => string
15
+ defaultValue?: IsRequired extends true ? string : string | null
16
+ i18n?: Partial<Record<Locale, Partial<TwLicensePlateMessages>>>
17
+ }
18
+
19
+ export type TwLicensePlateSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
20
+
21
+ const CAR_PATTERNS = [
22
+ /^[A-Z]{3}\d{4}$/, // ABC-1234 (new)
23
+ /^\d{4}[A-Z]{2}$/, // 1234-AB (new/legacy)
24
+ /^[A-Z]{2}\d{4}$/, // AB-1234 (legacy)
25
+ /^[A-Z]\d{5}$/, // A1-2345 -> A12345 (legacy mixed)
26
+ ]
27
+
28
+ const MOTORCYCLE_PATTERNS = [
29
+ /^[A-Z]{3}\d{4}$/, // ABC-1234
30
+ /^\d{3}[A-Z]{3}$/, // 123-ABC
31
+ /^[A-Z]{2}\d{4}$/, // AB-1234 (legacy)
32
+ /^\d{4}[A-Z]{2}$/, // 1234-AB (legacy)
33
+ ]
34
+
35
+ export const validateTaiwanLicensePlate = (value: string, plateType: PlateType = "any"): boolean => {
36
+ const patterns: RegExp[] = []
37
+
38
+ if (plateType === "car" || plateType === "any") {
39
+ patterns.push(...CAR_PATTERNS)
40
+ }
41
+
42
+ if (plateType === "motorcycle" || plateType === "any") {
43
+ for (const pattern of MOTORCYCLE_PATTERNS) {
44
+ if (!patterns.some((p) => p.source === pattern.source)) {
45
+ patterns.push(pattern)
46
+ }
47
+ }
48
+ }
49
+
50
+ return patterns.some((pattern) => pattern.test(value))
51
+ }
52
+
53
+ export function twLicensePlate<IsRequired extends boolean = false>(required?: IsRequired, options?: Omit<TwLicensePlateOptions<IsRequired>, "required">): TwLicensePlateSchema<IsRequired> {
54
+ const { plateType = "any", transform, defaultValue, i18n } = options ?? {}
55
+
56
+ const isRequired = required ?? (false as IsRequired)
57
+
58
+ const actualDefaultValue = defaultValue ?? (isRequired ? "" : null)
59
+
60
+ const getMessage = (key: keyof TwLicensePlateMessages, params?: Record<string, any>) => {
61
+ if (i18n) {
62
+ const currentLocale = getLocale()
63
+ const customMessages = i18n[currentLocale]
64
+ if (customMessages && customMessages[key]) {
65
+ const template = customMessages[key]!
66
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
67
+ }
68
+ }
69
+ return t(`taiwan.licensePlate.${key}`, params)
70
+ }
71
+
72
+ const preprocessFn = (val: unknown) => {
73
+ if (val === "" || val === null || val === undefined) {
74
+ return actualDefaultValue
75
+ }
76
+
77
+ let processed = String(val).trim().toUpperCase().replace(/[-\s]/g, "")
78
+
79
+ if (processed === "" && !required) {
80
+ return null
81
+ }
82
+
83
+ if (transform) {
84
+ processed = transform(processed)
85
+ }
86
+
87
+ return processed
88
+ }
89
+
90
+ const baseSchema = isRequired ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
91
+
92
+ const schema = baseSchema.superRefine((val, ctx) => {
93
+ if (val === null) return
94
+
95
+ if (isRequired && (val === "" || val === "null" || val === "undefined")) {
96
+ ctx.addIssue({ code: "custom", message: getMessage("required") })
97
+ return
98
+ }
99
+
100
+ if (val === null) return
101
+ if (!isRequired && val === "") return
102
+
103
+ if (!validateTaiwanLicensePlate(val, plateType)) {
104
+ ctx.addIssue({ code: "custom", message: getMessage("invalid") })
105
+ return
106
+ }
107
+ })
108
+
109
+ return schema as unknown as TwLicensePlateSchema<IsRequired>
110
+ }
@@ -0,0 +1,103 @@
1
+ import { z, ZodNullable, ZodString } from "zod"
2
+ import { t } from "../../i18n"
3
+ import { getLocale, type Locale } from "../../config"
4
+
5
+ export type PassportType = "ordinary" | "diplomatic" | "official" | "travel" | "any"
6
+
7
+ export type TwPassportMessages = {
8
+ required?: string
9
+ invalid?: string
10
+ }
11
+
12
+ export type TwPassportOptions<IsRequired extends boolean = true> = {
13
+ passportType?: PassportType
14
+ transform?: (value: string) => string
15
+ defaultValue?: IsRequired extends true ? string : string | null
16
+ i18n?: Partial<Record<Locale, Partial<TwPassportMessages>>>
17
+ }
18
+
19
+ export type TwPassportSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
20
+
21
+ const PASSPORT_TYPE_DIGIT: Record<Exclude<PassportType, "any">, string> = {
22
+ diplomatic: "0",
23
+ official: "1",
24
+ ordinary: "2",
25
+ travel: "3",
26
+ }
27
+
28
+ const validateTaiwanPassport = (value: string): boolean => {
29
+ if (!/^\d{9}$/.test(value)) {
30
+ return false
31
+ }
32
+
33
+ const firstDigit = parseInt(value[0], 10)
34
+ return firstDigit >= 0 && firstDigit <= 3
35
+ }
36
+
37
+ export function twPassport<IsRequired extends boolean = false>(required?: IsRequired, options?: Omit<TwPassportOptions<IsRequired>, "required">): TwPassportSchema<IsRequired> {
38
+ const { passportType = "any", transform, defaultValue, i18n } = options ?? {}
39
+
40
+ const isRequired = required ?? (false as IsRequired)
41
+
42
+ const actualDefaultValue = defaultValue ?? (isRequired ? "" : null)
43
+
44
+ const getMessage = (key: keyof TwPassportMessages, params?: Record<string, any>) => {
45
+ if (i18n) {
46
+ const currentLocale = getLocale()
47
+ const customMessages = i18n[currentLocale]
48
+ if (customMessages && customMessages[key]) {
49
+ const template = customMessages[key]!
50
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
51
+ }
52
+ }
53
+ return t(`taiwan.passport.${key}`, params)
54
+ }
55
+
56
+ const preprocessFn = (val: unknown) => {
57
+ if (val === "" || val === null || val === undefined) {
58
+ return actualDefaultValue
59
+ }
60
+
61
+ let processed = String(val).trim()
62
+
63
+ if (processed === "" && !required) {
64
+ return null
65
+ }
66
+
67
+ if (transform) {
68
+ processed = transform(processed)
69
+ }
70
+
71
+ return processed
72
+ }
73
+
74
+ const baseSchema = isRequired ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
75
+
76
+ const schema = baseSchema.superRefine((val, ctx) => {
77
+ if (val === null) return
78
+
79
+ if (isRequired && (val === "" || val === "null" || val === "undefined")) {
80
+ ctx.addIssue({ code: "custom", message: getMessage("required") })
81
+ return
82
+ }
83
+
84
+ if (!isRequired && val === "") return
85
+
86
+ if (!validateTaiwanPassport(val)) {
87
+ ctx.addIssue({ code: "custom", message: getMessage("invalid") })
88
+ return
89
+ }
90
+
91
+ if (passportType !== "any") {
92
+ const expectedDigit = PASSPORT_TYPE_DIGIT[passportType]
93
+ if (val[0] !== expectedDigit) {
94
+ ctx.addIssue({ code: "custom", message: getMessage("invalid") })
95
+ return
96
+ }
97
+ }
98
+ })
99
+
100
+ return schema as unknown as TwPassportSchema<IsRequired>
101
+ }
102
+
103
+ export { validateTaiwanPassport }