@hy_ong/zod-kit 0.0.2 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +7 -7
  4. package/debug.js +21 -0
  5. package/debug.ts +16 -0
  6. package/dist/index.cjs +1663 -189
  7. package/dist/index.d.cts +324 -32
  8. package/dist/index.d.ts +324 -32
  9. package/dist/index.js +1634 -187
  10. package/eslint.config.mts +8 -0
  11. package/package.json +10 -9
  12. package/src/config.ts +1 -1
  13. package/src/i18n/locales/en.json +123 -49
  14. package/src/i18n/locales/zh-TW.json +123 -46
  15. package/src/index.ts +13 -7
  16. package/src/validators/common/boolean.ts +97 -0
  17. package/src/validators/common/date.ts +171 -0
  18. package/src/validators/common/email.ts +200 -0
  19. package/src/validators/common/id.ts +259 -0
  20. package/src/validators/common/number.ts +194 -0
  21. package/src/validators/common/password.ts +214 -0
  22. package/src/validators/common/text.ts +151 -0
  23. package/src/validators/common/url.ts +207 -0
  24. package/src/validators/taiwan/business-id.ts +140 -0
  25. package/src/validators/taiwan/fax.ts +182 -0
  26. package/src/validators/taiwan/mobile.ts +110 -0
  27. package/src/validators/taiwan/national-id.ts +208 -0
  28. package/src/validators/taiwan/tel.ts +182 -0
  29. package/tests/common/boolean.test.ts +340 -92
  30. package/tests/common/date.test.ts +458 -0
  31. package/tests/common/email.test.ts +232 -60
  32. package/tests/common/id.test.ts +535 -0
  33. package/tests/common/number.test.ts +230 -60
  34. package/tests/common/password.test.ts +281 -54
  35. package/tests/common/text.test.ts +227 -30
  36. package/tests/common/url.test.ts +492 -67
  37. package/tests/taiwan/business-id.test.ts +240 -0
  38. package/tests/taiwan/fax.test.ts +463 -0
  39. package/tests/taiwan/mobile.test.ts +373 -0
  40. package/tests/taiwan/national-id.test.ts +435 -0
  41. package/tests/taiwan/tel.test.ts +467 -0
  42. package/eslint.config.mjs +0 -10
  43. package/src/common/boolean.ts +0 -37
  44. package/src/common/date.ts +0 -44
  45. package/src/common/email.ts +0 -45
  46. package/src/common/integer.ts +0 -47
  47. package/src/common/number.ts +0 -38
  48. package/src/common/password.ts +0 -34
  49. package/src/common/text.ts +0 -35
  50. package/src/common/url.ts +0 -38
  51. package/tests/common/integer.test.ts +0 -90
@@ -0,0 +1,208 @@
1
+ import { z, ZodNullable, ZodString } from "zod"
2
+ import { t } from "../../i18n"
3
+ import { getLocale, type Locale } from "../../config"
4
+
5
+ export type NationalIdMessages = {
6
+ required?: string
7
+ invalid?: string
8
+ }
9
+
10
+ export type NationalIdType =
11
+ | "citizen" // 身分證字號 (國民身分證)
12
+ | "resident" // 居留證號 (外籍人士統一證號)
13
+ | "both" // 身分證/居留證皆可
14
+
15
+ export type NationalIdOptions<IsRequired extends boolean = true> = {
16
+ required?: IsRequired
17
+ type?: NationalIdType
18
+ allowOldResident?: boolean
19
+ transform?: (value: string) => string
20
+ defaultValue?: IsRequired extends true ? string : string | null
21
+ i18n?: Record<Locale, NationalIdMessages>
22
+ }
23
+
24
+ export type NationalIdSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
25
+
26
+ // 縣市代碼對應表
27
+ const CITY_CODES: Record<string, number> = {
28
+ 'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15, 'G': 16, 'H': 17,
29
+ 'I': 34, 'J': 18, 'K': 19, 'L': 20, 'M': 21, 'N': 22, 'O': 35, 'P': 23,
30
+ 'Q': 24, 'R': 25, 'S': 26, 'T': 27, 'U': 28, 'V': 29, 'W': 32, 'X': 30,
31
+ 'Y': 31, 'Z': 33
32
+ }
33
+
34
+ // 驗證國民身分證字號
35
+ const validateCitizenId = (value: string): boolean => {
36
+ // 格式檢查:1個英文字母 + 9個數字
37
+ if (!/^[A-Z][1-2]\d{8}$/.test(value)) {
38
+ return false
39
+ }
40
+
41
+ const letter = value[0]
42
+ const digits = value.slice(1).split('').map(Number)
43
+
44
+ // 獲取縣市代碼
45
+ const cityCode = CITY_CODES[letter]
46
+ if (!cityCode) return false
47
+
48
+ // 計算校驗碼
49
+ const cityDigits = [Math.floor(cityCode / 10), cityCode % 10]
50
+ const coefficients = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1]
51
+
52
+ let sum = cityDigits[0] * coefficients[0] + cityDigits[1] * coefficients[1]
53
+ for (let i = 0; i < 8; i++) {
54
+ sum += digits[i] * coefficients[i + 2]
55
+ }
56
+
57
+ const checksum = (10 - (sum % 10)) % 10
58
+ return checksum === digits[8]
59
+ }
60
+
61
+ // 驗證舊式居留證號
62
+ const validateOldResidentId = (value: string): boolean => {
63
+ // 格式檢查:1個英文字母 + [AB或CD] + 8個數字
64
+ if (!/^[A-Z][ABCD]\d{8}$/.test(value)) {
65
+ return false
66
+ }
67
+
68
+ const letter = value[0]
69
+ const genderCode = value[1]
70
+ const digits = value.slice(2).split('').map(Number)
71
+
72
+ // 獲取縣市代碼
73
+ const cityCode = CITY_CODES[letter]
74
+ if (!cityCode) return false
75
+
76
+ // 性別代碼轉換
77
+ const genderValue = genderCode === 'A' || genderCode === 'C' ? 1 : 0
78
+
79
+ // 計算校驗碼
80
+ const cityDigits = [Math.floor(cityCode / 10), cityCode % 10]
81
+ const coefficients = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1]
82
+
83
+ let sum = cityDigits[0] * coefficients[0] + cityDigits[1] * coefficients[1] + genderValue * coefficients[2]
84
+ for (let i = 0; i < 7; i++) {
85
+ sum += digits[i] * coefficients[i + 3]
86
+ }
87
+
88
+ const checksum = (10 - (sum % 10)) % 10
89
+ return checksum === digits[7]
90
+ }
91
+
92
+ // 驗證新式居留證號
93
+ const validateNewResidentId = (value: string): boolean => {
94
+ // 格式檢查:1個英文字母 + [89] + 1個數字[0-9] + 7個數字
95
+ if (!/^[A-Z][89]\d{8}$/.test(value)) {
96
+ return false
97
+ }
98
+
99
+ const letter = value[0]
100
+ const digits = value.slice(1).split('').map(Number)
101
+
102
+ // 獲取縣市代碼
103
+ const cityCode = CITY_CODES[letter]
104
+ if (!cityCode) return false
105
+
106
+ // 計算校驗碼 (與身分證字號相同邏輯)
107
+ const cityDigits = [Math.floor(cityCode / 10), cityCode % 10]
108
+ const coefficients = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1]
109
+
110
+ let sum = cityDigits[0] * coefficients[0] + cityDigits[1] * coefficients[1]
111
+ for (let i = 0; i < 8; i++) {
112
+ sum += digits[i] * coefficients[i + 2]
113
+ }
114
+
115
+ const checksum = (10 - (sum % 10)) % 10
116
+ return checksum === digits[8]
117
+ }
118
+
119
+ // 主要驗證函數
120
+ const validateTaiwanNationalId = (value: string, type: NationalIdType = "both", allowOldResident: boolean = true): boolean => {
121
+ if (!/^[A-Z].{9}$/.test(value)) {
122
+ return false
123
+ }
124
+
125
+ switch (type) {
126
+ case "citizen":
127
+ return validateCitizenId(value)
128
+ case "resident":
129
+ return (allowOldResident ? validateOldResidentId(value) : false) || validateNewResidentId(value)
130
+ case "both":
131
+ return validateCitizenId(value) || (allowOldResident ? validateOldResidentId(value) : false) || validateNewResidentId(value)
132
+ default:
133
+ return false
134
+ }
135
+ }
136
+
137
+ export function nationalId<IsRequired extends boolean = true>(options?: NationalIdOptions<IsRequired>): NationalIdSchema<IsRequired> {
138
+ const {
139
+ required = true,
140
+ type = "both",
141
+ allowOldResident = true,
142
+ transform,
143
+ defaultValue,
144
+ i18n
145
+ } = options ?? {}
146
+
147
+ // Set appropriate default value based on required flag
148
+ const actualDefaultValue = defaultValue ?? (required ? "" : null)
149
+
150
+ // Helper function to get custom message or fallback to default i18n
151
+ const getMessage = (key: keyof NationalIdMessages, params?: Record<string, any>) => {
152
+ if (i18n) {
153
+ const currentLocale = getLocale()
154
+ const customMessages = i18n[currentLocale]
155
+ if (customMessages && customMessages[key]) {
156
+ const template = customMessages[key]!
157
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
158
+ }
159
+ }
160
+ return t(`taiwan.nationalId.${key}`, params)
161
+ }
162
+
163
+ // Preprocessing function
164
+ const preprocessFn = (val: unknown) => {
165
+ if (val === "" || val === null || val === undefined) {
166
+ return actualDefaultValue
167
+ }
168
+
169
+ let processed = String(val).trim().toUpperCase()
170
+
171
+ // If after trimming we have an empty string and the field is optional, return null
172
+ if (processed === "" && !required) {
173
+ return null
174
+ }
175
+
176
+ if (transform) {
177
+ processed = transform(processed)
178
+ }
179
+
180
+ return processed
181
+ }
182
+
183
+ const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
184
+
185
+ const schema = baseSchema.refine((val) => {
186
+ if (val === null) return true
187
+
188
+ // Required check
189
+ if (required && (val === "" || val === "null" || val === "undefined")) {
190
+ throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
191
+ }
192
+
193
+ if (val === null) return true
194
+ if (!required && val === "") return true
195
+
196
+ // Taiwan National ID validation
197
+ if (!validateTaiwanNationalId(val, type, allowOldResident)) {
198
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
199
+ }
200
+
201
+ return true
202
+ })
203
+
204
+ return schema as unknown as NationalIdSchema<IsRequired>
205
+ }
206
+
207
+ // Export utility functions for external use
208
+ export { validateTaiwanNationalId, validateCitizenId, validateOldResidentId, validateNewResidentId }
@@ -0,0 +1,182 @@
1
+ import { z, ZodNullable, ZodString } from "zod"
2
+ import { t } from "../../i18n"
3
+ import { getLocale, type Locale } from "../../config"
4
+
5
+ export type TelMessages = {
6
+ required?: string
7
+ invalid?: string
8
+ notInWhitelist?: string
9
+ }
10
+
11
+ export type TelOptions<IsRequired extends boolean = true> = {
12
+ required?: IsRequired
13
+ whitelist?: string[]
14
+ transform?: (value: string) => string
15
+ defaultValue?: IsRequired extends true ? string : string | null
16
+ i18n?: Record<Locale, TelMessages>
17
+ }
18
+
19
+ export type TelSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
20
+
21
+ // Taiwan landline telephone validation (Official 2024 rules)
22
+ const validateTaiwanTel = (value: string): boolean => {
23
+ // Official Taiwan landline formats according to telecom numbering plan:
24
+ // 02: Taipei, New Taipei, Keelung - 8 digits (2&3&5~8+7D)
25
+ // 03: Taoyuan, Hsinchu, Yilan, Hualien - 7 digits
26
+ // 037: Miaoli - 6 digits (2~9+5D)
27
+ // 04: Taichung, Changhua - 7 digits
28
+ // 049: Nantou - 7 digits (2~9+6D)
29
+ // 05: Yunlin, Chiayi - 7 digits
30
+ // 06: Tainan - 7 digits
31
+ // 07: Kaohsiung - 7 digits (2~9+6D)
32
+ // 08: Pingtung - 7 digits (4&7&8+6D)
33
+ // 082: Kinmen - 6 digits (2~5&7~9+5D)
34
+ // 0826: Wuqiu - 5 digits (6+4D)
35
+ // 0836: Matsu - 5 digits (2~9+4D)
36
+ // 089: Taitung - 6 digits (2~9+5D)
37
+
38
+ // Remove common separators for validation
39
+ const cleanValue = value.replace(/[-\s]/g, "")
40
+
41
+ // Basic format: starts with 0, then area code, then number
42
+ if (!/^0\d{7,10}$/.test(cleanValue)) {
43
+ return false
44
+ }
45
+
46
+ // Check 4-digit area codes first
47
+ const areaCode4 = cleanValue.substring(0, 4)
48
+ if (areaCode4 === "0826") {
49
+ // Wuqiu: 0826 + 5 digits (6+4D), total 9 digits
50
+ return cleanValue.length === 9 && /^0826[6]\d{4}$/.test(cleanValue)
51
+ }
52
+ if (areaCode4 === "0836") {
53
+ // Matsu: 0836 + 5 digits (2~9+4D), total 9 digits
54
+ return cleanValue.length === 9 && /^0836[2-9]\d{4}$/.test(cleanValue)
55
+ }
56
+
57
+ // Check 3-digit area codes
58
+ const areaCode3 = cleanValue.substring(0, 3)
59
+ if (areaCode3 === "037") {
60
+ // Miaoli: 037 + 6 digits (2~9+5D), total 9 digits
61
+ return cleanValue.length === 9 && /^037[2-9]\d{5}$/.test(cleanValue)
62
+ }
63
+ if (areaCode3 === "049") {
64
+ // Nantou: 049 + 7 digits (2~9+6D), total 10 digits
65
+ return cleanValue.length === 10 && /^049[2-9]\d{6}$/.test(cleanValue)
66
+ }
67
+ if (areaCode3 === "082") {
68
+ // Kinmen: 082 + 6 digits (2~5&7~9+5D), total 9 digits
69
+ return cleanValue.length === 9 && /^082[2-57-9]\d{5}$/.test(cleanValue)
70
+ }
71
+ if (areaCode3 === "089") {
72
+ // Taitung: 089 + 6 digits (2~9+5D), total 9 digits
73
+ return cleanValue.length === 9 && /^089[2-9]\d{5}$/.test(cleanValue)
74
+ }
75
+
76
+ // Check 2-digit area codes
77
+ const areaCode2 = cleanValue.substring(0, 2)
78
+
79
+ if (areaCode2 === "02") {
80
+ // Taipei, New Taipei, Keelung: 02 + 8 digits (2&3&5~8+7D), total 10 digits
81
+ return cleanValue.length === 10 && /^02[235-8]\d{7}$/.test(cleanValue)
82
+ }
83
+ if (["03", "04", "05", "06"].includes(areaCode2)) {
84
+ // Taoyuan/Hsinchu/Yilan/Hualien (03), Taichung/Changhua (04),
85
+ // Yunlin/Chiayi (05), Tainan (06): 7 digits, total 9 digits
86
+ return cleanValue.length === 9
87
+ }
88
+ if (areaCode2 === "07") {
89
+ // Kaohsiung: 07 + 7 digits (2~9+6D), total 9 digits
90
+ return cleanValue.length === 9 && /^07[2-9]\d{6}$/.test(cleanValue)
91
+ }
92
+ if (areaCode2 === "08") {
93
+ // Pingtung: 08 + 7 digits (4&7&8+6D), total 9 digits
94
+ return cleanValue.length === 9 && /^08[478]\d{6}$/.test(cleanValue)
95
+ }
96
+
97
+ return false
98
+ }
99
+
100
+ export function tel<IsRequired extends boolean = true>(options?: TelOptions<IsRequired>): TelSchema<IsRequired> {
101
+ const { required = true, whitelist, transform, defaultValue, i18n } = options ?? {}
102
+
103
+ // Set appropriate default value based on required flag
104
+ const actualDefaultValue = defaultValue ?? (required ? "" : null)
105
+
106
+ // Helper function to get custom message or fallback to default i18n
107
+ const getMessage = (key: keyof TelMessages, params?: Record<string, any>) => {
108
+ if (i18n) {
109
+ const currentLocale = getLocale()
110
+ const customMessages = i18n[currentLocale]
111
+ if (customMessages && customMessages[key]) {
112
+ const template = customMessages[key]!
113
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
114
+ }
115
+ }
116
+ return t(`taiwan.tel.${key}`, params)
117
+ }
118
+
119
+ // Preprocessing function
120
+ const preprocessFn = (val: unknown) => {
121
+ if (val === null || val === undefined) {
122
+ return actualDefaultValue
123
+ }
124
+
125
+ let processed = String(val).trim()
126
+
127
+ // If after trimming we have an empty string
128
+ if (processed === "") {
129
+ // If empty string is in allowlist, return it as is
130
+ if (whitelist && whitelist.includes("")) {
131
+ return ""
132
+ }
133
+ // If the field is optional and empty string not in allowlist, return default value
134
+ if (!required) {
135
+ return actualDefaultValue
136
+ }
137
+ // If a field is required, return the default value (will be validated later)
138
+ return actualDefaultValue
139
+ }
140
+
141
+ if (transform) {
142
+ processed = transform(processed)
143
+ }
144
+
145
+ return processed
146
+ }
147
+
148
+ const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
149
+
150
+ const schema = baseSchema.refine((val) => {
151
+ if (val === null) return true
152
+
153
+ // Required check
154
+ if (required && (val === "" || val === "null" || val === "undefined")) {
155
+ throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
156
+ }
157
+
158
+ if (val === null) return true
159
+ if (!required && val === "") return true
160
+
161
+ // Allowlist check (if an allowlist is provided, only allow values in the allowlist)
162
+ if (whitelist && whitelist.length > 0) {
163
+ if (whitelist.includes(val)) {
164
+ return true
165
+ }
166
+ // If not in the allowlist, reject regardless of format
167
+ throw new z.ZodError([{ code: "custom", message: getMessage("notInWhitelist"), path: [] }])
168
+ }
169
+
170
+ // Taiwan telephone format validation (only if no allowlist or allowlist is empty)
171
+ if (!validateTaiwanTel(val)) {
172
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
173
+ }
174
+
175
+ return true
176
+ })
177
+
178
+ return schema as unknown as TelSchema<IsRequired>
179
+ }
180
+
181
+ // Export utility function for external use
182
+ export { validateTaiwanTel }