@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.
- package/.github/workflows/ci.yml +24 -0
- package/CLAUDE.md +64 -22
- package/dist/chunk-36NWHESN.js +124 -0
- package/dist/chunk-4LYZAO3P.js +165 -0
- package/dist/chunk-5GAZQDVS.cjs +206 -0
- package/dist/chunk-5LS4DSRQ.cjs +127 -0
- package/dist/chunk-5OGW2ERW.js +181 -0
- package/dist/chunk-5ZEKWPSE.cjs +69 -0
- package/dist/chunk-6OGDPSWT.js +135 -0
- package/dist/chunk-6X22I6NQ.cjs +136 -0
- package/dist/chunk-77KZUPPN.cjs +177 -0
- package/dist/chunk-AANSHH2O.cjs +165 -0
- package/dist/chunk-AI72FMOF.cjs +130 -0
- package/dist/chunk-AWV2IT66.js +146 -0
- package/dist/chunk-B3U5G3AA.js +160 -0
- package/dist/chunk-CFFCBWYL.cjs +99 -0
- package/dist/chunk-DPXRMSB2.js +130 -0
- package/dist/chunk-DRXPGQM6.cjs +135 -0
- package/dist/chunk-EAU42EVH.js +161 -0
- package/dist/chunk-FC6VDOC7.js +206 -0
- package/dist/chunk-FVO4743A.cjs +134 -0
- package/dist/chunk-G6DV7LX7.cjs +161 -0
- package/dist/chunk-I2RJMDXN.js +90 -0
- package/dist/chunk-IJEEM3DI.js +136 -0
- package/dist/chunk-JBNCMS42.cjs +151 -0
- package/dist/chunk-JZ2SHRGZ.js +87 -0
- package/dist/chunk-KARFFIMP.js +696 -0
- package/dist/chunk-LIQSVJLS.js +177 -0
- package/dist/chunk-LKPXHW5N.cjs +181 -0
- package/dist/chunk-MAQRXYE6.js +118 -0
- package/dist/chunk-MCDESS3T.js +69 -0
- package/dist/chunk-MG25BEV4.cjs +160 -0
- package/dist/chunk-NKCYXBGX.js +99 -0
- package/dist/chunk-OEK7QSQP.js +75 -0
- package/dist/chunk-OMFQ7Z63.cjs +696 -0
- package/dist/chunk-OP4KV3BY.cjs +124 -0
- package/dist/chunk-P2NONIMS.js +257 -0
- package/dist/chunk-P364KRO5.js +61 -0
- package/dist/chunk-PGSDXR2I.js +71 -0
- package/dist/chunk-PL2GERLG.cjs +61 -0
- package/dist/chunk-R5G4V7C6.cjs +75 -0
- package/dist/chunk-RKHX3DGH.js +127 -0
- package/dist/chunk-TSHL7ZO2.js +134 -0
- package/dist/chunk-UFNVCUPQ.cjs +301 -0
- package/dist/chunk-VCRKYMJM.js +301 -0
- package/dist/chunk-VDOAPLA6.cjs +257 -0
- package/dist/chunk-VP5CCP5F.cjs +90 -0
- package/dist/chunk-W2EWMV3A.cjs +87 -0
- package/dist/chunk-WWRFBLCR.cjs +146 -0
- package/dist/chunk-YALLOVNO.cjs +118 -0
- package/dist/chunk-YAU6JCYL.cjs +71 -0
- package/dist/chunk-YWV2BBXN.cjs +2526 -0
- package/dist/chunk-ZBOQCXD4.js +2526 -0
- package/dist/chunk-ZFQQXWNB.js +151 -0
- package/dist/common/boolean.cjs +7 -0
- package/dist/common/boolean.d.cts +119 -0
- package/dist/common/boolean.d.ts +119 -0
- package/dist/common/boolean.js +7 -0
- package/dist/common/color.cjs +9 -0
- package/dist/common/color.d.cts +26 -0
- package/dist/common/color.d.ts +26 -0
- package/dist/common/color.js +9 -0
- package/dist/common/coordinate.cjs +11 -0
- package/dist/common/coordinate.d.cts +23 -0
- package/dist/common/coordinate.d.ts +23 -0
- package/dist/common/coordinate.js +11 -0
- package/dist/common/credit-card.cjs +11 -0
- package/dist/common/credit-card.d.cts +22 -0
- package/dist/common/credit-card.d.ts +22 -0
- package/dist/common/credit-card.js +11 -0
- package/dist/common/date.cjs +7 -0
- package/dist/common/date.d.cts +174 -0
- package/dist/common/date.d.ts +174 -0
- package/dist/common/date.js +7 -0
- package/dist/common/datetime.cjs +15 -0
- package/dist/common/datetime.d.cts +301 -0
- package/dist/common/datetime.d.ts +301 -0
- package/dist/common/datetime.js +15 -0
- package/dist/common/email.cjs +7 -0
- package/dist/common/email.d.cts +149 -0
- package/dist/common/email.d.ts +149 -0
- package/dist/common/email.js +7 -0
- package/dist/common/file.cjs +7 -0
- package/dist/common/file.d.cts +178 -0
- package/dist/common/file.d.ts +178 -0
- package/dist/common/file.js +7 -0
- package/dist/common/id.cjs +13 -0
- package/dist/common/id.d.cts +262 -0
- package/dist/common/id.d.ts +262 -0
- package/dist/common/id.js +13 -0
- package/dist/common/ip.cjs +11 -0
- package/dist/common/ip.d.cts +25 -0
- package/dist/common/ip.d.ts +25 -0
- package/dist/common/ip.js +11 -0
- package/dist/common/number.cjs +7 -0
- package/dist/common/number.d.cts +167 -0
- package/dist/common/number.d.ts +167 -0
- package/dist/common/number.js +7 -0
- package/dist/common/password.cjs +7 -0
- package/dist/common/password.d.cts +192 -0
- package/dist/common/password.d.ts +192 -0
- package/dist/common/password.js +7 -0
- package/dist/common/text.cjs +7 -0
- package/dist/common/text.d.cts +156 -0
- package/dist/common/text.d.ts +156 -0
- package/dist/common/text.js +7 -0
- package/dist/common/time.cjs +15 -0
- package/dist/common/time.d.cts +268 -0
- package/dist/common/time.d.ts +268 -0
- package/dist/common/time.js +15 -0
- package/dist/common/url.cjs +7 -0
- package/dist/common/url.d.cts +196 -0
- package/dist/common/url.d.ts +196 -0
- package/dist/common/url.js +7 -0
- package/dist/config-CABSSvAp.d.cts +5 -0
- package/dist/config-CABSSvAp.d.ts +5 -0
- package/dist/index.cjs +180 -5255
- package/dist/index.d.cts +28 -3150
- package/dist/index.d.ts +28 -3150
- package/dist/index.js +135 -5131
- package/dist/taiwan/bank-account.cjs +11 -0
- package/dist/taiwan/bank-account.d.cts +22 -0
- package/dist/taiwan/bank-account.d.ts +22 -0
- package/dist/taiwan/bank-account.js +11 -0
- package/dist/taiwan/business-id.cjs +9 -0
- package/dist/taiwan/business-id.d.cts +133 -0
- package/dist/taiwan/business-id.d.ts +133 -0
- package/dist/taiwan/business-id.js +9 -0
- package/dist/taiwan/fax.cjs +9 -0
- package/dist/taiwan/fax.d.cts +157 -0
- package/dist/taiwan/fax.d.ts +157 -0
- package/dist/taiwan/fax.js +9 -0
- package/dist/taiwan/invoice.cjs +9 -0
- package/dist/taiwan/invoice.d.cts +17 -0
- package/dist/taiwan/invoice.d.ts +17 -0
- package/dist/taiwan/invoice.js +9 -0
- package/dist/taiwan/license-plate.cjs +9 -0
- package/dist/taiwan/license-plate.d.cts +19 -0
- package/dist/taiwan/license-plate.d.ts +19 -0
- package/dist/taiwan/license-plate.js +9 -0
- package/dist/taiwan/mobile.cjs +9 -0
- package/dist/taiwan/mobile.d.cts +146 -0
- package/dist/taiwan/mobile.d.ts +146 -0
- package/dist/taiwan/mobile.js +9 -0
- package/dist/taiwan/national-id.cjs +15 -0
- package/dist/taiwan/national-id.d.cts +214 -0
- package/dist/taiwan/national-id.d.ts +214 -0
- package/dist/taiwan/national-id.js +15 -0
- package/dist/taiwan/passport.cjs +9 -0
- package/dist/taiwan/passport.d.cts +19 -0
- package/dist/taiwan/passport.d.ts +19 -0
- package/dist/taiwan/passport.js +9 -0
- package/dist/taiwan/postal-code.cjs +17 -0
- package/dist/taiwan/postal-code.d.cts +237 -0
- package/dist/taiwan/postal-code.d.ts +237 -0
- package/dist/taiwan/postal-code.js +17 -0
- package/dist/taiwan/tel.cjs +9 -0
- package/dist/taiwan/tel.d.cts +162 -0
- package/dist/taiwan/tel.d.ts +162 -0
- package/dist/taiwan/tel.js +9 -0
- package/package.json +128 -2
- package/src/i18n/locales/en-GB.json +43 -0
- package/src/i18n/locales/en-US.json +43 -0
- package/src/i18n/locales/id-ID.json +43 -0
- package/src/i18n/locales/ja-JP.json +43 -0
- package/src/i18n/locales/ko-KR.json +43 -0
- package/src/i18n/locales/ms-MY.json +43 -0
- package/src/i18n/locales/th-TH.json +43 -0
- package/src/i18n/locales/vi-VN.json +43 -0
- package/src/i18n/locales/zh-CN.json +43 -0
- package/src/i18n/locales/zh-TW.json +43 -0
- package/src/index.ts +10 -2
- package/src/validators/common/color.ts +192 -0
- package/src/validators/common/coordinate.ts +159 -0
- package/src/validators/common/credit-card.ts +134 -0
- package/src/validators/common/ip.ts +210 -0
- package/src/validators/taiwan/bank-account.ts +176 -0
- package/src/validators/taiwan/invoice.ts +84 -0
- package/src/validators/taiwan/license-plate.ts +110 -0
- package/src/validators/taiwan/passport.ts +103 -0
- package/tests/common/color.test.ts +587 -0
- package/tests/common/coordinate.test.ts +345 -0
- package/tests/common/credit-card.test.ts +378 -0
- package/tests/common/ip.test.ts +419 -0
- package/tests/taiwan/bank-account.test.ts +286 -0
- package/tests/taiwan/invoice.test.ts +227 -0
- package/tests/taiwan/license-plate.test.ts +280 -0
- package/tests/taiwan/passport.test.ts +277 -0
- 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 }
|