@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.
Files changed (191) hide show
  1. package/.github/workflows/ci.yml +24 -0
  2. package/CLAUDE.md +64 -22
  3. package/dist/chunk-2SWEVDFZ.js +134 -0
  4. package/dist/chunk-32JI34CV.cjs +146 -0
  5. package/dist/chunk-42C5OHRK.js +71 -0
  6. package/dist/chunk-46VAH2BJ.js +160 -0
  7. package/dist/chunk-5JGTDL3Y.js +87 -0
  8. package/dist/chunk-5LEXCVLX.js +257 -0
  9. package/dist/chunk-6AAP4LPF.js +2606 -0
  10. package/dist/chunk-B4EZYZOK.cjs +215 -0
  11. package/dist/chunk-COYKBWTI.js +161 -0
  12. package/dist/chunk-DFJZ3NS2.cjs +151 -0
  13. package/dist/chunk-EDHT4LPO.js +118 -0
  14. package/dist/chunk-EGHL277K.cjs +165 -0
  15. package/dist/chunk-ERH4NIMU.cjs +69 -0
  16. package/dist/chunk-FM3EZ72O.js +165 -0
  17. package/dist/chunk-GJIRDBZJ.cjs +90 -0
  18. package/dist/chunk-H2XTEM4M.js +696 -0
  19. package/dist/chunk-HMSM6FFA.cjs +181 -0
  20. package/dist/chunk-HTEHINI7.cjs +177 -0
  21. package/dist/chunk-JOLSGZGN.cjs +696 -0
  22. package/dist/chunk-JXY7APBU.js +69 -0
  23. package/dist/chunk-K2UOY6TB.js +136 -0
  24. package/dist/chunk-KFOHKTFD.js +61 -0
  25. package/dist/chunk-L4HSIKTU.cjs +135 -0
  26. package/dist/chunk-LH7ZB4BK.js +124 -0
  27. package/dist/chunk-LL4ZWLGO.js +90 -0
  28. package/dist/chunk-M6MTP3NY.cjs +99 -0
  29. package/dist/chunk-MHJFYYGV.js +215 -0
  30. package/dist/chunk-MINMXGW3.js +135 -0
  31. package/dist/chunk-MM7IL2RG.js +181 -0
  32. package/dist/chunk-OPQJWHXN.cjs +301 -0
  33. package/dist/chunk-ORFHDJII.cjs +136 -0
  34. package/dist/chunk-ORVV4MCF.cjs +87 -0
  35. package/dist/chunk-QICQ6YEY.js +75 -0
  36. package/dist/chunk-RKUQREMW.js +127 -0
  37. package/dist/chunk-RO47DKQG.js +146 -0
  38. package/dist/chunk-RRPXIRTQ.cjs +257 -0
  39. package/dist/chunk-RYFG2GKM.cjs +118 -0
  40. package/dist/chunk-STNHTRG7.cjs +124 -0
  41. package/dist/chunk-TFGS34VD.cjs +71 -0
  42. package/dist/chunk-TQXDUMML.cjs +61 -0
  43. package/dist/chunk-UBK3VCVH.cjs +134 -0
  44. package/dist/chunk-UCOXAZJF.cjs +2606 -0
  45. package/dist/chunk-UQZKFAFX.js +130 -0
  46. package/dist/chunk-VB2KV2ZM.cjs +130 -0
  47. package/dist/chunk-WABKPFPK.js +151 -0
  48. package/dist/chunk-WDI4QJMQ.js +177 -0
  49. package/dist/chunk-YDH3L27K.cjs +127 -0
  50. package/dist/chunk-YIM3D2AD.js +99 -0
  51. package/dist/chunk-YPSEIDUR.cjs +160 -0
  52. package/dist/chunk-ZNJLWJX3.cjs +75 -0
  53. package/dist/chunk-ZTFCJCPO.cjs +161 -0
  54. package/dist/chunk-ZXUMK2RR.js +301 -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 +288 -0
  89. package/dist/common/id.d.ts +288 -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 +132 -6
  162. package/src/i18n/locales/en-GB.json +51 -0
  163. package/src/i18n/locales/en-US.json +52 -1
  164. package/src/i18n/locales/id-ID.json +51 -0
  165. package/src/i18n/locales/ja-JP.json +51 -0
  166. package/src/i18n/locales/ko-KR.json +51 -0
  167. package/src/i18n/locales/ms-MY.json +51 -0
  168. package/src/i18n/locales/th-TH.json +51 -0
  169. package/src/i18n/locales/vi-VN.json +51 -0
  170. package/src/i18n/locales/zh-CN.json +51 -0
  171. package/src/i18n/locales/zh-TW.json +51 -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/id.ts +45 -3
  177. package/src/validators/common/ip.ts +210 -0
  178. package/src/validators/taiwan/bank-account.ts +176 -0
  179. package/src/validators/taiwan/invoice.ts +84 -0
  180. package/src/validators/taiwan/license-plate.ts +110 -0
  181. package/src/validators/taiwan/passport.ts +103 -0
  182. package/tests/common/color.test.ts +587 -0
  183. package/tests/common/coordinate.test.ts +345 -0
  184. package/tests/common/credit-card.test.ts +378 -0
  185. package/tests/common/id.test.ts +68 -3
  186. package/tests/common/ip.test.ts +419 -0
  187. package/tests/taiwan/bank-account.test.ts +286 -0
  188. package/tests/taiwan/invoice.test.ts +227 -0
  189. package/tests/taiwan/license-plate.test.ts +280 -0
  190. package/tests/taiwan/passport.test.ts +277 -0
  191. 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 v4 format (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
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 v4 format
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-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
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 }