@hy_ong/zod-kit 0.2.0 → 0.2.2
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-2SWEVDFZ.js +134 -0
- package/dist/chunk-32JI34CV.cjs +146 -0
- package/dist/chunk-42C5OHRK.js +71 -0
- package/dist/chunk-46VAH2BJ.js +160 -0
- package/dist/chunk-5JGTDL3Y.js +87 -0
- package/dist/chunk-5LEXCVLX.js +257 -0
- package/dist/chunk-6AAP4LPF.js +2606 -0
- package/dist/chunk-B4EZYZOK.cjs +215 -0
- package/dist/chunk-COYKBWTI.js +161 -0
- package/dist/chunk-DFJZ3NS2.cjs +151 -0
- package/dist/chunk-EDHT4LPO.js +118 -0
- package/dist/chunk-EGHL277K.cjs +165 -0
- package/dist/chunk-ERH4NIMU.cjs +69 -0
- package/dist/chunk-FM3EZ72O.js +165 -0
- package/dist/chunk-GJIRDBZJ.cjs +90 -0
- package/dist/chunk-H2XTEM4M.js +696 -0
- package/dist/chunk-HMSM6FFA.cjs +181 -0
- package/dist/chunk-HTEHINI7.cjs +177 -0
- package/dist/chunk-JOLSGZGN.cjs +696 -0
- package/dist/chunk-JXY7APBU.js +69 -0
- package/dist/chunk-K2UOY6TB.js +136 -0
- package/dist/chunk-KFOHKTFD.js +61 -0
- package/dist/chunk-L4HSIKTU.cjs +135 -0
- package/dist/chunk-LH7ZB4BK.js +124 -0
- package/dist/chunk-LL4ZWLGO.js +90 -0
- package/dist/chunk-M6MTP3NY.cjs +99 -0
- package/dist/chunk-MHJFYYGV.js +215 -0
- package/dist/chunk-MINMXGW3.js +135 -0
- package/dist/chunk-MM7IL2RG.js +181 -0
- package/dist/chunk-OPQJWHXN.cjs +301 -0
- package/dist/chunk-ORFHDJII.cjs +136 -0
- package/dist/chunk-ORVV4MCF.cjs +87 -0
- package/dist/chunk-QICQ6YEY.js +75 -0
- package/dist/chunk-RKUQREMW.js +127 -0
- package/dist/chunk-RO47DKQG.js +146 -0
- package/dist/chunk-RRPXIRTQ.cjs +257 -0
- package/dist/chunk-RYFG2GKM.cjs +118 -0
- package/dist/chunk-STNHTRG7.cjs +124 -0
- package/dist/chunk-TFGS34VD.cjs +71 -0
- package/dist/chunk-TQXDUMML.cjs +61 -0
- package/dist/chunk-UBK3VCVH.cjs +134 -0
- package/dist/chunk-UCOXAZJF.cjs +2606 -0
- package/dist/chunk-UQZKFAFX.js +130 -0
- package/dist/chunk-VB2KV2ZM.cjs +130 -0
- package/dist/chunk-WABKPFPK.js +151 -0
- package/dist/chunk-WDI4QJMQ.js +177 -0
- package/dist/chunk-YDH3L27K.cjs +127 -0
- package/dist/chunk-YIM3D2AD.js +99 -0
- package/dist/chunk-YPSEIDUR.cjs +160 -0
- package/dist/chunk-ZNJLWJX3.cjs +75 -0
- package/dist/chunk-ZTFCJCPO.cjs +161 -0
- package/dist/chunk-ZXUMK2RR.js +301 -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 +288 -0
- package/dist/common/id.d.ts +288 -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 +132 -6
- package/src/i18n/locales/en-GB.json +51 -0
- package/src/i18n/locales/en-US.json +52 -1
- package/src/i18n/locales/id-ID.json +51 -0
- package/src/i18n/locales/ja-JP.json +51 -0
- package/src/i18n/locales/ko-KR.json +51 -0
- package/src/i18n/locales/ms-MY.json +51 -0
- package/src/i18n/locales/th-TH.json +51 -0
- package/src/i18n/locales/vi-VN.json +51 -0
- package/src/i18n/locales/zh-CN.json +51 -0
- package/src/i18n/locales/zh-TW.json +51 -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/id.ts +45 -3
- 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/id.test.ts +68 -3
- 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,134 @@
|
|
|
1
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
2
|
+
import { t } from "../../i18n"
|
|
3
|
+
import { getLocale, type Locale } from "../../config"
|
|
4
|
+
|
|
5
|
+
export type CreditCardType = "visa" | "mastercard" | "amex" | "jcb" | "discover" | "unionpay" | "any"
|
|
6
|
+
|
|
7
|
+
export type CreditCardMessages = {
|
|
8
|
+
required?: string
|
|
9
|
+
invalid?: string
|
|
10
|
+
notInWhitelist?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type CreditCardOptions<IsRequired extends boolean = true> = {
|
|
14
|
+
cardType?: CreditCardType | CreditCardType[]
|
|
15
|
+
whitelist?: string[]
|
|
16
|
+
transform?: (value: string) => string
|
|
17
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
18
|
+
i18n?: Partial<Record<Locale, Partial<CreditCardMessages>>>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CreditCardSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
22
|
+
|
|
23
|
+
export function detectCardType(value: string): CreditCardType {
|
|
24
|
+
const digits = value.replace(/[\s-]/g, "")
|
|
25
|
+
|
|
26
|
+
if (/^4/.test(digits)) return "visa"
|
|
27
|
+
if (/^5[1-5]/.test(digits) || /^2[2-7]/.test(digits)) return "mastercard"
|
|
28
|
+
if (/^3[47]/.test(digits)) return "amex"
|
|
29
|
+
if (/^35/.test(digits)) return "jcb"
|
|
30
|
+
if (/^6011/.test(digits) || /^65/.test(digits) || /^64[4-9]/.test(digits)) return "discover"
|
|
31
|
+
if (/^62/.test(digits)) return "unionpay"
|
|
32
|
+
|
|
33
|
+
return "any"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateCreditCard(value: string): boolean {
|
|
37
|
+
const digits = value.replace(/[\s-]/g, "")
|
|
38
|
+
|
|
39
|
+
if (!/^\d{13,19}$/.test(digits)) return false
|
|
40
|
+
|
|
41
|
+
let sum = 0
|
|
42
|
+
let alternate = false
|
|
43
|
+
|
|
44
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
45
|
+
let n = parseInt(digits[i], 10)
|
|
46
|
+
|
|
47
|
+
if (alternate) {
|
|
48
|
+
n *= 2
|
|
49
|
+
if (n > 9) n -= 9
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
sum += n
|
|
53
|
+
alternate = !alternate
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return sum % 10 === 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function creditCard<IsRequired extends boolean = false>(required?: IsRequired, options?: Omit<CreditCardOptions<IsRequired>, "required">): CreditCardSchema<IsRequired> {
|
|
60
|
+
const { cardType, whitelist, transform, defaultValue, i18n } = options ?? {}
|
|
61
|
+
|
|
62
|
+
const isRequired = required ?? (false as IsRequired)
|
|
63
|
+
|
|
64
|
+
const actualDefaultValue = defaultValue ?? (isRequired ? "" : null)
|
|
65
|
+
|
|
66
|
+
const getMessage = (key: keyof CreditCardMessages, params?: Record<string, any>) => {
|
|
67
|
+
if (i18n) {
|
|
68
|
+
const currentLocale = getLocale()
|
|
69
|
+
const customMessages = i18n[currentLocale]
|
|
70
|
+
if (customMessages && customMessages[key]) {
|
|
71
|
+
const template = customMessages[key]!
|
|
72
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return t(`common.creditCard.${key}`, params)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const preprocessFn = (val: unknown) => {
|
|
79
|
+
if (val === "" || val === null || val === undefined) {
|
|
80
|
+
return actualDefaultValue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let processed = String(val).trim().replace(/[\s-]/g, "")
|
|
84
|
+
|
|
85
|
+
if (processed === "" && !required) {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (transform) {
|
|
90
|
+
processed = transform(processed)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return processed
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const baseSchema = isRequired ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
97
|
+
|
|
98
|
+
const schema = baseSchema.superRefine((val, ctx) => {
|
|
99
|
+
if (val === null) return
|
|
100
|
+
|
|
101
|
+
if (isRequired && (val === "" || val === "null" || val === "undefined")) {
|
|
102
|
+
ctx.addIssue({ code: "custom", message: getMessage("required") })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!isRequired && val === "") return
|
|
107
|
+
|
|
108
|
+
if (!validateCreditCard(val)) {
|
|
109
|
+
ctx.addIssue({ code: "custom", message: getMessage("invalid") })
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (cardType) {
|
|
114
|
+
const allowedTypes = Array.isArray(cardType) ? cardType : [cardType]
|
|
115
|
+
if (!allowedTypes.includes("any")) {
|
|
116
|
+
const detected = detectCardType(val)
|
|
117
|
+
if (!allowedTypes.includes(detected)) {
|
|
118
|
+
ctx.addIssue({ code: "custom", message: getMessage("invalid") })
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (whitelist && whitelist.length > 0) {
|
|
125
|
+
const normalized = whitelist.map((w) => w.replace(/[\s-]/g, ""))
|
|
126
|
+
if (!normalized.includes(val)) {
|
|
127
|
+
ctx.addIssue({ code: "custom", message: getMessage("notInWhitelist") })
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return schema as unknown as CreditCardSchema<IsRequired>
|
|
134
|
+
}
|
|
@@ -22,6 +22,14 @@ import { getLocale, type Locale } from "../../config"
|
|
|
22
22
|
* @property {string} [maxLength] - Message when ID is too long
|
|
23
23
|
* @property {string} [numeric] - Message when numeric ID format is invalid
|
|
24
24
|
* @property {string} [uuid] - Message when UUID format is invalid
|
|
25
|
+
* @property {string} [uuidv1] - Message when UUID v1 format is invalid
|
|
26
|
+
* @property {string} [uuidv2] - Message when UUID v2 format is invalid
|
|
27
|
+
* @property {string} [uuidv3] - Message when UUID v3 format is invalid
|
|
28
|
+
* @property {string} [uuidv4] - Message when UUID v4 format is invalid
|
|
29
|
+
* @property {string} [uuidv5] - Message when UUID v5 format is invalid
|
|
30
|
+
* @property {string} [uuidv6] - Message when UUID v6 format is invalid
|
|
31
|
+
* @property {string} [uuidv7] - Message when UUID v7 format is invalid
|
|
32
|
+
* @property {string} [uuidv8] - Message when UUID v8 format is invalid
|
|
25
33
|
* @property {string} [objectId] - Message when MongoDB ObjectId format is invalid
|
|
26
34
|
* @property {string} [nanoid] - Message when Nano ID format is invalid
|
|
27
35
|
* @property {string} [snowflake] - Message when Snowflake ID format is invalid
|
|
@@ -41,6 +49,14 @@ export type IdMessages = {
|
|
|
41
49
|
maxLength?: string
|
|
42
50
|
numeric?: string
|
|
43
51
|
uuid?: string
|
|
52
|
+
uuidv1?: string
|
|
53
|
+
uuidv2?: string
|
|
54
|
+
uuidv3?: string
|
|
55
|
+
uuidv4?: string
|
|
56
|
+
uuidv5?: string
|
|
57
|
+
uuidv6?: string
|
|
58
|
+
uuidv7?: string
|
|
59
|
+
uuidv8?: string
|
|
44
60
|
objectId?: string
|
|
45
61
|
nanoid?: string
|
|
46
62
|
snowflake?: string
|
|
@@ -61,7 +77,15 @@ export type IdMessages = {
|
|
|
61
77
|
*
|
|
62
78
|
* Available types:
|
|
63
79
|
* - numeric: Pure numeric IDs (1, 123, 999999)
|
|
64
|
-
* - uuid: UUID
|
|
80
|
+
* - uuid: UUID any version (v1–v8)
|
|
81
|
+
* - uuidv1: UUID v1 (timestamp-based)
|
|
82
|
+
* - uuidv2: UUID v2 (DCE security)
|
|
83
|
+
* - uuidv3: UUID v3 (MD5 name-based)
|
|
84
|
+
* - uuidv4: UUID v4 (random)
|
|
85
|
+
* - uuidv5: UUID v5 (SHA-1 name-based)
|
|
86
|
+
* - uuidv6: UUID v6 (reordered timestamp, RFC 9562)
|
|
87
|
+
* - uuidv7: UUID v7 (Unix timestamp, sortable, RFC 9562)
|
|
88
|
+
* - uuidv8: UUID v8 (custom, RFC 9562)
|
|
65
89
|
* - objectId: MongoDB ObjectId (24-character hexadecimal)
|
|
66
90
|
* - nanoid: Nano ID format (21-character URL-safe)
|
|
67
91
|
* - snowflake: Twitter Snowflake (19-digit number)
|
|
@@ -72,7 +96,15 @@ export type IdMessages = {
|
|
|
72
96
|
*/
|
|
73
97
|
export type IdType =
|
|
74
98
|
| "numeric" // Pure numeric IDs (1, 123, 999999)
|
|
75
|
-
| "uuid" // UUID
|
|
99
|
+
| "uuid" // UUID any version (v1–v8)
|
|
100
|
+
| "uuidv1" // UUID v1 (timestamp-based)
|
|
101
|
+
| "uuidv2" // UUID v2 (DCE security)
|
|
102
|
+
| "uuidv3" // UUID v3 (MD5 name-based)
|
|
103
|
+
| "uuidv4" // UUID v4 (random)
|
|
104
|
+
| "uuidv5" // UUID v5 (SHA-1 name-based)
|
|
105
|
+
| "uuidv6" // UUID v6 (reordered timestamp, RFC 9562)
|
|
106
|
+
| "uuidv7" // UUID v7 (Unix timestamp, sortable, RFC 9562)
|
|
107
|
+
| "uuidv8" // UUID v8 (custom, RFC 9562)
|
|
76
108
|
| "objectId" // MongoDB ObjectId (24-character hexadecimal)
|
|
77
109
|
| "nanoid" // Nano ID
|
|
78
110
|
| "snowflake" // Twitter Snowflake (19-digit number)
|
|
@@ -143,9 +175,19 @@ export type IdSchema<IsRequired extends boolean, Type extends IdType | undefined
|
|
|
143
175
|
* @constant {Record<string, RegExp>} ID_PATTERNS
|
|
144
176
|
* @description Maps each ID type to its corresponding regex pattern
|
|
145
177
|
*/
|
|
178
|
+
const UUID_BASE = (version: string) => new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${version}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, "i")
|
|
179
|
+
|
|
146
180
|
const ID_PATTERNS = {
|
|
147
181
|
numeric: /^\d+$/,
|
|
148
|
-
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[1-
|
|
182
|
+
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
183
|
+
uuidv1: UUID_BASE("1"),
|
|
184
|
+
uuidv2: UUID_BASE("2"),
|
|
185
|
+
uuidv3: UUID_BASE("3"),
|
|
186
|
+
uuidv4: UUID_BASE("4"),
|
|
187
|
+
uuidv5: UUID_BASE("5"),
|
|
188
|
+
uuidv6: UUID_BASE("6"),
|
|
189
|
+
uuidv7: UUID_BASE("7"),
|
|
190
|
+
uuidv8: UUID_BASE("8"),
|
|
149
191
|
objectId: /^[0-9a-f]{24}$/i,
|
|
150
192
|
nanoid: /^[A-Za-z0-9_-]{21}$/,
|
|
151
193
|
snowflake: /^\d{19}$/,
|
|
@@ -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 }
|