@hy_ong/zod-kit 0.0.4 → 0.0.6
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/.claude/settings.local.json +28 -0
- package/LICENSE +21 -0
- package/README.md +465 -97
- package/debug.js +21 -0
- package/debug.ts +16 -0
- package/dist/index.cjs +3127 -146
- package/dist/index.d.cts +3021 -25
- package/dist/index.d.ts +3021 -25
- package/dist/index.js +3081 -144
- package/eslint.config.mts +8 -0
- package/package.json +10 -9
- package/src/config.ts +1 -1
- package/src/i18n/locales/en.json +161 -25
- package/src/i18n/locales/zh-TW.json +165 -26
- package/src/index.ts +17 -7
- package/src/validators/common/boolean.ts +191 -0
- package/src/validators/common/date.ts +299 -0
- package/src/validators/common/datetime.ts +673 -0
- package/src/validators/common/email.ts +313 -0
- package/src/validators/common/file.ts +384 -0
- package/src/validators/common/id.ts +471 -0
- package/src/validators/common/number.ts +319 -0
- package/src/validators/common/password.ts +386 -0
- package/src/validators/common/text.ts +271 -0
- package/src/validators/common/time.ts +600 -0
- package/src/validators/common/url.ts +347 -0
- package/src/validators/taiwan/business-id.ts +262 -0
- package/src/validators/taiwan/fax.ts +327 -0
- package/src/validators/taiwan/mobile.ts +242 -0
- package/src/validators/taiwan/national-id.ts +425 -0
- package/src/validators/taiwan/postal-code.ts +1049 -0
- package/src/validators/taiwan/tel.ts +330 -0
- package/tests/common/boolean.test.ts +340 -92
- package/tests/common/date.test.ts +458 -0
- package/tests/common/datetime.test.ts +693 -0
- package/tests/common/email.test.ts +232 -60
- package/tests/common/file.test.ts +479 -0
- package/tests/common/id.test.ts +535 -0
- package/tests/common/number.test.ts +230 -60
- package/tests/common/password.test.ts +271 -44
- package/tests/common/text.test.ts +210 -13
- package/tests/common/time.test.ts +528 -0
- package/tests/common/url.test.ts +492 -67
- package/tests/taiwan/business-id.test.ts +240 -0
- package/tests/taiwan/fax.test.ts +463 -0
- package/tests/taiwan/mobile.test.ts +373 -0
- package/tests/taiwan/national-id.test.ts +435 -0
- package/tests/taiwan/postal-code.test.ts +705 -0
- package/tests/taiwan/tel.test.ts +467 -0
- package/eslint.config.mjs +0 -10
- package/src/common/boolean.ts +0 -36
- package/src/common/date.ts +0 -43
- package/src/common/email.ts +0 -44
- package/src/common/integer.ts +0 -46
- package/src/common/number.ts +0 -37
- package/src/common/password.ts +0 -33
- package/src/common/text.ts +0 -34
- package/src/common/url.ts +0 -37
- package/tests/common/integer.test.ts +0 -90
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview URL validator for Zod Kit
|
|
3
|
+
*
|
|
4
|
+
* Provides comprehensive URL validation with protocol filtering, domain control,
|
|
5
|
+
* port validation, path constraints, and localhost handling.
|
|
6
|
+
*
|
|
7
|
+
* @author Ong Hoe Yuan
|
|
8
|
+
* @version 0.0.5
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
12
|
+
import { t } from "../../i18n"
|
|
13
|
+
import { getLocale, type Locale } from "../../config"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Type definition for URL validation error messages
|
|
17
|
+
*
|
|
18
|
+
* @interface UrlMessages
|
|
19
|
+
* @property {string} [required] - Message when field is required but empty
|
|
20
|
+
* @property {string} [invalid] - Message when URL format is invalid
|
|
21
|
+
* @property {string} [min] - Message when URL is too short
|
|
22
|
+
* @property {string} [max] - Message when URL is too long
|
|
23
|
+
* @property {string} [includes] - Message when URL doesn't contain required string
|
|
24
|
+
* @property {string} [excludes] - Message when URL contains forbidden string
|
|
25
|
+
* @property {string} [protocol] - Message when protocol is not allowed
|
|
26
|
+
* @property {string} [domain] - Message when domain is not allowed
|
|
27
|
+
* @property {string} [domainBlacklist] - Message when domain is blacklisted
|
|
28
|
+
* @property {string} [port] - Message when port is not allowed
|
|
29
|
+
* @property {string} [pathStartsWith] - Message when path doesn't start with required string
|
|
30
|
+
* @property {string} [pathEndsWith] - Message when path doesn't end with required string
|
|
31
|
+
* @property {string} [hasQuery] - Message when query parameters are required
|
|
32
|
+
* @property {string} [noQuery] - Message when query parameters are forbidden
|
|
33
|
+
* @property {string} [hasFragment] - Message when fragment is required
|
|
34
|
+
* @property {string} [noFragment] - Message when fragment is forbidden
|
|
35
|
+
* @property {string} [localhost] - Message when localhost is forbidden
|
|
36
|
+
* @property {string} [noLocalhost] - Message when localhost is required
|
|
37
|
+
*/
|
|
38
|
+
export type UrlMessages = {
|
|
39
|
+
required?: string
|
|
40
|
+
invalid?: string
|
|
41
|
+
min?: string
|
|
42
|
+
max?: string
|
|
43
|
+
includes?: string
|
|
44
|
+
excludes?: string
|
|
45
|
+
protocol?: string
|
|
46
|
+
domain?: string
|
|
47
|
+
domainBlacklist?: string
|
|
48
|
+
port?: string
|
|
49
|
+
pathStartsWith?: string
|
|
50
|
+
pathEndsWith?: string
|
|
51
|
+
hasQuery?: string
|
|
52
|
+
noQuery?: string
|
|
53
|
+
hasFragment?: string
|
|
54
|
+
noFragment?: string
|
|
55
|
+
localhost?: string
|
|
56
|
+
noLocalhost?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Configuration options for URL validation
|
|
61
|
+
*
|
|
62
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
63
|
+
*
|
|
64
|
+
* @interface UrlOptions
|
|
65
|
+
* @property {IsRequired} [required=true] - Whether the field is required
|
|
66
|
+
* @property {number} [min] - Minimum length of URL
|
|
67
|
+
* @property {number} [max] - Maximum length of URL
|
|
68
|
+
* @property {string} [includes] - String that must be included in URL
|
|
69
|
+
* @property {string | string[]} [excludes] - String(s) that must not be included
|
|
70
|
+
* @property {string[]} [protocols] - Allowed protocols (e.g., ["https", "http"])
|
|
71
|
+
* @property {string[]} [allowedDomains] - Domains that are allowed
|
|
72
|
+
* @property {string[]} [blockedDomains] - Domains that are blocked
|
|
73
|
+
* @property {number[]} [allowedPorts] - Ports that are allowed
|
|
74
|
+
* @property {number[]} [blockedPorts] - Ports that are blocked
|
|
75
|
+
* @property {string} [pathStartsWith] - Path must start with this string
|
|
76
|
+
* @property {string} [pathEndsWith] - Path must end with this string
|
|
77
|
+
* @property {boolean} [mustHaveQuery] - Whether URL must have query parameters
|
|
78
|
+
* @property {boolean} [mustNotHaveQuery] - Whether URL must not have query parameters
|
|
79
|
+
* @property {boolean} [mustHaveFragment] - Whether URL must have fragment
|
|
80
|
+
* @property {boolean} [mustNotHaveFragment] - Whether URL must not have fragment
|
|
81
|
+
* @property {boolean} [allowLocalhost=true] - Whether to allow localhost URLs
|
|
82
|
+
* @property {boolean} [blockLocalhost] - Whether to explicitly block localhost URLs
|
|
83
|
+
* @property {Function} [transform] - Custom transformation function for URL strings
|
|
84
|
+
* @property {string | null} [defaultValue] - Default value when input is empty
|
|
85
|
+
* @property {Record<Locale, UrlMessages>} [i18n] - Custom error messages for different locales
|
|
86
|
+
*/
|
|
87
|
+
export type UrlOptions<IsRequired extends boolean = true> = {
|
|
88
|
+
required?: IsRequired
|
|
89
|
+
min?: number
|
|
90
|
+
max?: number
|
|
91
|
+
includes?: string
|
|
92
|
+
excludes?: string | string[]
|
|
93
|
+
protocols?: string[]
|
|
94
|
+
allowedDomains?: string[]
|
|
95
|
+
blockedDomains?: string[]
|
|
96
|
+
allowedPorts?: number[]
|
|
97
|
+
blockedPorts?: number[]
|
|
98
|
+
pathStartsWith?: string
|
|
99
|
+
pathEndsWith?: string
|
|
100
|
+
mustHaveQuery?: boolean
|
|
101
|
+
mustNotHaveQuery?: boolean
|
|
102
|
+
mustHaveFragment?: boolean
|
|
103
|
+
mustNotHaveFragment?: boolean
|
|
104
|
+
allowLocalhost?: boolean
|
|
105
|
+
blockLocalhost?: boolean
|
|
106
|
+
transform?: (value: string) => string
|
|
107
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
108
|
+
i18n?: Record<Locale, UrlMessages>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Type alias for URL validation schema based on required flag
|
|
113
|
+
*
|
|
114
|
+
* @template IsRequired - Whether the field is required
|
|
115
|
+
* @typedef UrlSchema
|
|
116
|
+
* @description Returns ZodString if required, ZodNullable<ZodString> if optional
|
|
117
|
+
*/
|
|
118
|
+
export type UrlSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Creates a Zod schema for URL validation with comprehensive constraints
|
|
122
|
+
*
|
|
123
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
124
|
+
* @param {UrlOptions<IsRequired>} [options] - Configuration options for URL validation
|
|
125
|
+
* @returns {UrlSchema<IsRequired>} Zod schema for URL validation
|
|
126
|
+
*
|
|
127
|
+
* @description
|
|
128
|
+
* Creates a comprehensive URL validator with protocol filtering, domain control,
|
|
129
|
+
* port validation, path constraints, and localhost handling.
|
|
130
|
+
*
|
|
131
|
+
* Features:
|
|
132
|
+
* - RFC-compliant URL format validation
|
|
133
|
+
* - Protocol whitelist/blacklist (http, https, ftp, etc.)
|
|
134
|
+
* - Domain whitelist/blacklist with subdomain support
|
|
135
|
+
* - Port validation and filtering
|
|
136
|
+
* - Path prefix/suffix validation
|
|
137
|
+
* - Query parameter requirements
|
|
138
|
+
* - Fragment requirements
|
|
139
|
+
* - Localhost detection and control
|
|
140
|
+
* - Length validation
|
|
141
|
+
* - Content inclusion/exclusion
|
|
142
|
+
* - Custom transformation functions
|
|
143
|
+
* - Comprehensive internationalization
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```typescript
|
|
147
|
+
* // Basic URL validation
|
|
148
|
+
* const basicSchema = url()
|
|
149
|
+
* basicSchema.parse("https://example.com") // ✓ Valid
|
|
150
|
+
*
|
|
151
|
+
* // HTTPS only
|
|
152
|
+
* const httpsSchema = url({ protocols: ["https"] })
|
|
153
|
+
* httpsSchema.parse("https://example.com") // ✓ Valid
|
|
154
|
+
* httpsSchema.parse("http://example.com") // ✗ Invalid
|
|
155
|
+
*
|
|
156
|
+
* // Domain restriction
|
|
157
|
+
* const domainSchema = url({
|
|
158
|
+
* allowedDomains: ["company.com", "trusted.org"]
|
|
159
|
+
* })
|
|
160
|
+
* domainSchema.parse("https://app.company.com") // ✓ Valid (subdomain)
|
|
161
|
+
* domainSchema.parse("https://example.com") // ✗ Invalid
|
|
162
|
+
*
|
|
163
|
+
* // Block localhost
|
|
164
|
+
* const noLocalhostSchema = url({ blockLocalhost: true })
|
|
165
|
+
* noLocalhostSchema.parse("https://example.com") // ✓ Valid
|
|
166
|
+
* noLocalhostSchema.parse("http://localhost:3000") // ✗ Invalid
|
|
167
|
+
*
|
|
168
|
+
* // API endpoints with path requirements
|
|
169
|
+
* const apiSchema = url({
|
|
170
|
+
* pathStartsWith: "/api/",
|
|
171
|
+
* mustHaveQuery: true
|
|
172
|
+
* })
|
|
173
|
+
* apiSchema.parse("https://api.com/api/users?page=1") // ✓ Valid
|
|
174
|
+
*
|
|
175
|
+
* // Port restrictions
|
|
176
|
+
* const portSchema = url({
|
|
177
|
+
* allowedPorts: [80, 443, 8080]
|
|
178
|
+
* })
|
|
179
|
+
* portSchema.parse("https://example.com:443") // ✓ Valid
|
|
180
|
+
* portSchema.parse("https://example.com:3000") // ✗ Invalid
|
|
181
|
+
*
|
|
182
|
+
* // Optional with default
|
|
183
|
+
* const optionalSchema = url({
|
|
184
|
+
* required: false,
|
|
185
|
+
* defaultValue: null
|
|
186
|
+
* })
|
|
187
|
+
* ```
|
|
188
|
+
*
|
|
189
|
+
* @throws {z.ZodError} When validation fails with specific error messages
|
|
190
|
+
* @see {@link UrlOptions} for all available configuration options
|
|
191
|
+
*/
|
|
192
|
+
export function url<IsRequired extends boolean = true>(options?: UrlOptions<IsRequired>): UrlSchema<IsRequired> {
|
|
193
|
+
const {
|
|
194
|
+
required = true,
|
|
195
|
+
min,
|
|
196
|
+
max,
|
|
197
|
+
includes,
|
|
198
|
+
excludes,
|
|
199
|
+
protocols,
|
|
200
|
+
allowedDomains,
|
|
201
|
+
blockedDomains,
|
|
202
|
+
allowedPorts,
|
|
203
|
+
blockedPorts,
|
|
204
|
+
pathStartsWith,
|
|
205
|
+
pathEndsWith,
|
|
206
|
+
mustHaveQuery,
|
|
207
|
+
mustNotHaveQuery,
|
|
208
|
+
mustHaveFragment,
|
|
209
|
+
mustNotHaveFragment,
|
|
210
|
+
allowLocalhost = true,
|
|
211
|
+
blockLocalhost,
|
|
212
|
+
transform,
|
|
213
|
+
defaultValue = null,
|
|
214
|
+
i18n,
|
|
215
|
+
} = options ?? {}
|
|
216
|
+
|
|
217
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
218
|
+
|
|
219
|
+
// Helper function to get custom message or fallback to default i18n
|
|
220
|
+
const getMessage = (key: keyof UrlMessages, params?: Record<string, any>) => {
|
|
221
|
+
if (i18n) {
|
|
222
|
+
const currentLocale = getLocale()
|
|
223
|
+
const customMessages = i18n[currentLocale]
|
|
224
|
+
if (customMessages && customMessages[key]) {
|
|
225
|
+
const template = customMessages[key]!
|
|
226
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return t(`common.url.${key}`, params)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Preprocessing function with transformations
|
|
233
|
+
const preprocessFn = (val: unknown) => {
|
|
234
|
+
if (val === "" || val === null || val === undefined) {
|
|
235
|
+
return actualDefaultValue
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let processed = String(val).trim()
|
|
239
|
+
|
|
240
|
+
if (transform) {
|
|
241
|
+
processed = transform(processed)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return processed
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
248
|
+
|
|
249
|
+
const schema = baseSchema.refine((val) => {
|
|
250
|
+
if (val === null) return true
|
|
251
|
+
|
|
252
|
+
// Required check
|
|
253
|
+
if (required && (val === "" || val === "null" || val === "undefined")) {
|
|
254
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// URL format validation
|
|
258
|
+
let urlObj: URL
|
|
259
|
+
try {
|
|
260
|
+
urlObj = new URL(val)
|
|
261
|
+
} catch {
|
|
262
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Length checks
|
|
266
|
+
if (val !== null && min !== undefined && val.length < min) {
|
|
267
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("min", { min }), path: [] }])
|
|
268
|
+
}
|
|
269
|
+
if (val !== null && max !== undefined && val.length > max) {
|
|
270
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("max", { max }), path: [] }])
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// String content checks
|
|
274
|
+
if (val !== null && includes !== undefined && !val.includes(includes)) {
|
|
275
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
|
|
276
|
+
}
|
|
277
|
+
if (val !== null && excludes !== undefined) {
|
|
278
|
+
const excludeList = Array.isArray(excludes) ? excludes : [excludes]
|
|
279
|
+
for (const exclude of excludeList) {
|
|
280
|
+
if (val.includes(exclude)) {
|
|
281
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Protocol validation
|
|
287
|
+
if (protocols && !protocols.includes(urlObj.protocol.slice(0, -1))) {
|
|
288
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("protocol", { protocols: protocols.join(", ") }), path: [] }])
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Domain validation
|
|
292
|
+
const hostname = urlObj.hostname.toLowerCase()
|
|
293
|
+
if (allowedDomains && !allowedDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`))) {
|
|
294
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("domain", { domains: allowedDomains.join(", ") }), path: [] }])
|
|
295
|
+
}
|
|
296
|
+
if (blockedDomains && blockedDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`))) {
|
|
297
|
+
const blockedDomain = blockedDomains.find((domain) => hostname === domain || hostname.endsWith(`.${domain}`))
|
|
298
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("domainBlacklist", { domain: blockedDomain }), path: [] }])
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Port validation
|
|
302
|
+
const port = urlObj.port ? parseInt(urlObj.port) : urlObj.protocol === "https:" ? 443 : 80
|
|
303
|
+
if (allowedPorts && !allowedPorts.includes(port)) {
|
|
304
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("port", { ports: allowedPorts.join(", ") }), path: [] }])
|
|
305
|
+
}
|
|
306
|
+
if (blockedPorts && blockedPorts.includes(port)) {
|
|
307
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("port", { port }), path: [] }])
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Path validation
|
|
311
|
+
if (pathStartsWith && !urlObj.pathname.startsWith(pathStartsWith)) {
|
|
312
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("pathStartsWith", { path: pathStartsWith }), path: [] }])
|
|
313
|
+
}
|
|
314
|
+
if (pathEndsWith && !urlObj.pathname.endsWith(pathEndsWith)) {
|
|
315
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("pathEndsWith", { path: pathEndsWith }), path: [] }])
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Query validation
|
|
319
|
+
if (mustHaveQuery && !urlObj.search) {
|
|
320
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("hasQuery"), path: [] }])
|
|
321
|
+
}
|
|
322
|
+
if (mustNotHaveQuery && urlObj.search) {
|
|
323
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noQuery"), path: [] }])
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Fragment validation
|
|
327
|
+
if (mustHaveFragment && !urlObj.hash) {
|
|
328
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("hasFragment"), path: [] }])
|
|
329
|
+
}
|
|
330
|
+
if (mustNotHaveFragment && urlObj.hash) {
|
|
331
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noFragment"), path: [] }])
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Localhost validation
|
|
335
|
+
const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname.startsWith("192.168.") || hostname.startsWith("10.") || hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)
|
|
336
|
+
if (blockLocalhost && isLocalhost) {
|
|
337
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noLocalhost"), path: [] }])
|
|
338
|
+
}
|
|
339
|
+
if (!allowLocalhost && isLocalhost) {
|
|
340
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("localhost"), path: [] }])
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return true
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
return schema as unknown as UrlSchema<IsRequired>
|
|
347
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Taiwan Business ID (統一編號) validator for Zod Kit
|
|
3
|
+
*
|
|
4
|
+
* Provides validation for Taiwan Business Identification Numbers (統一編號) with
|
|
5
|
+
* support for both new (2023+) and legacy validation rules.
|
|
6
|
+
*
|
|
7
|
+
* @author Ong Hoe Yuan
|
|
8
|
+
* @version 0.0.5
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
12
|
+
import { t } from "../../i18n"
|
|
13
|
+
import { getLocale, type Locale } from "../../config"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Type definition for business ID validation error messages
|
|
17
|
+
*
|
|
18
|
+
* @interface BusinessIdMessages
|
|
19
|
+
* @property {string} [required] - Message when field is required but empty
|
|
20
|
+
* @property {string} [invalid] - Message when business ID format or checksum is invalid
|
|
21
|
+
*/
|
|
22
|
+
export type BusinessIdMessages = {
|
|
23
|
+
required?: string
|
|
24
|
+
invalid?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configuration options for Taiwan business ID validation
|
|
29
|
+
*
|
|
30
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
31
|
+
*
|
|
32
|
+
* @interface BusinessIdOptions
|
|
33
|
+
* @property {IsRequired} [required=true] - Whether the field is required
|
|
34
|
+
* @property {Function} [transform] - Custom transformation function for business ID
|
|
35
|
+
* @property {string | null} [defaultValue] - Default value when input is empty
|
|
36
|
+
* @property {Record<Locale, BusinessIdMessages>} [i18n] - Custom error messages for different locales
|
|
37
|
+
*/
|
|
38
|
+
export type BusinessIdOptions<IsRequired extends boolean = true> = {
|
|
39
|
+
required?: IsRequired
|
|
40
|
+
transform?: (value: string) => string
|
|
41
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
42
|
+
i18n?: Record<Locale, BusinessIdMessages>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Type alias for business ID validation schema based on required flag
|
|
47
|
+
*
|
|
48
|
+
* @template IsRequired - Whether the field is required
|
|
49
|
+
* @typedef BusinessIdSchema
|
|
50
|
+
* @description Returns ZodString if required, ZodNullable<ZodString> if optional
|
|
51
|
+
*/
|
|
52
|
+
export type BusinessIdSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validates Taiwan Business Identification Number (統一編號)
|
|
56
|
+
*
|
|
57
|
+
* @param {string} value - The business ID to validate
|
|
58
|
+
* @returns {boolean} True if the business ID is valid
|
|
59
|
+
*
|
|
60
|
+
* @description
|
|
61
|
+
* Validates Taiwan Business ID using both new (2023+) and legacy validation rules.
|
|
62
|
+
* The validation includes format checking (8 digits) and checksum verification.
|
|
63
|
+
*
|
|
64
|
+
* Validation rules:
|
|
65
|
+
* 1. Must be exactly 8 digits
|
|
66
|
+
* 2. Weighted sum calculation using coefficients [1,2,1,2,1,2,4] for first 7 digits
|
|
67
|
+
* 3. New rules (2023+): Sum + 8th digit must be divisible by 5
|
|
68
|
+
* 4. Legacy rules: Sum + 8th digit must be divisible by 10
|
|
69
|
+
* 5. Special case: If 7th digit is 7, try alternative calculation with +1
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```typescript
|
|
73
|
+
* validateTaiwanBusinessId("12345675") // true (if valid checksum)
|
|
74
|
+
* validateTaiwanBusinessId("1234567") // false (not 8 digits)
|
|
75
|
+
* validateTaiwanBusinessId("abcd1234") // false (not all digits)
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
const validateTaiwanBusinessId = (value: string): boolean => {
|
|
79
|
+
// Must be exactly 8 digits
|
|
80
|
+
if (!/^\d{8}$/.test(value)) {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const digits = value.split('').map(Number)
|
|
85
|
+
|
|
86
|
+
// Coefficients for the first 7 digits
|
|
87
|
+
const coefficients = [1, 2, 1, 2, 1, 2, 4]
|
|
88
|
+
|
|
89
|
+
// Calculate weighted sum for first 7 digits
|
|
90
|
+
let sum = 0
|
|
91
|
+
for (let i = 0; i < 7; i++) {
|
|
92
|
+
const product = digits[i] * coefficients[i]
|
|
93
|
+
// Add individual digits of the product (split if >= 10)
|
|
94
|
+
sum += Math.floor(product / 10) + (product % 10)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Add the check digit (8th digit)
|
|
98
|
+
sum += digits[7]
|
|
99
|
+
|
|
100
|
+
// New rules (2023+): Valid if sum is divisible by 5
|
|
101
|
+
if (sum % 5 === 0) {
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Fall back to old rules: Valid if sum is divisible by 10
|
|
106
|
+
if (sum % 10 === 0) {
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Special case for old rules: if 7th digit is 7
|
|
111
|
+
if (digits[6] === 7) {
|
|
112
|
+
let altSum = 0
|
|
113
|
+
for (let i = 0; i < 7; i++) {
|
|
114
|
+
const product = digits[i] * coefficients[i]
|
|
115
|
+
altSum += Math.floor(product / 10) + (product % 10)
|
|
116
|
+
}
|
|
117
|
+
// Add 1 and check digit
|
|
118
|
+
altSum += 1 + digits[7]
|
|
119
|
+
|
|
120
|
+
// Check both new and old rules
|
|
121
|
+
if (altSum % 5 === 0 || altSum % 10 === 0) {
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Creates a Zod schema for Taiwan Business ID validation
|
|
131
|
+
*
|
|
132
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
133
|
+
* @param {BusinessIdOptions<IsRequired>} [options] - Configuration options for business ID validation
|
|
134
|
+
* @returns {BusinessIdSchema<IsRequired>} Zod schema for business ID validation
|
|
135
|
+
*
|
|
136
|
+
* @description
|
|
137
|
+
* Creates a comprehensive Taiwan Business ID validator that validates the format
|
|
138
|
+
* and checksum according to Taiwan government specifications.
|
|
139
|
+
*
|
|
140
|
+
* Features:
|
|
141
|
+
* - 8-digit format validation
|
|
142
|
+
* - Checksum verification (supports both new 2023+ and legacy rules)
|
|
143
|
+
* - Automatic trimming and preprocessing
|
|
144
|
+
* - Custom transformation functions
|
|
145
|
+
* - Comprehensive internationalization
|
|
146
|
+
* - Optional field support
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```typescript
|
|
150
|
+
* // Basic business ID validation
|
|
151
|
+
* const basicSchema = businessId()
|
|
152
|
+
* basicSchema.parse("12345675") // ✓ Valid (if checksum correct)
|
|
153
|
+
* basicSchema.parse("1234567") // ✗ Invalid (not 8 digits)
|
|
154
|
+
*
|
|
155
|
+
* // Optional business ID
|
|
156
|
+
* const optionalSchema = businessId({ required: false })
|
|
157
|
+
* optionalSchema.parse("") // ✓ Valid (returns null)
|
|
158
|
+
* optionalSchema.parse("12345675") // ✓ Valid (if checksum correct)
|
|
159
|
+
*
|
|
160
|
+
* // With custom transformation
|
|
161
|
+
* const transformSchema = businessId({
|
|
162
|
+
* transform: (value) => value.replace(/[^0-9]/g, '') // Remove non-digits
|
|
163
|
+
* })
|
|
164
|
+
* transformSchema.parse("1234-5675") // ✓ Valid (if checksum correct after cleaning)
|
|
165
|
+
*
|
|
166
|
+
* // With custom error messages
|
|
167
|
+
* const customSchema = businessId({
|
|
168
|
+
* i18n: {
|
|
169
|
+
* en: { invalid: "Please enter a valid Taiwan Business ID" },
|
|
170
|
+
* 'zh-TW': { invalid: "請輸入有效的統一編號" }
|
|
171
|
+
* }
|
|
172
|
+
* })
|
|
173
|
+
* ```
|
|
174
|
+
*
|
|
175
|
+
* @throws {z.ZodError} When validation fails with specific error messages
|
|
176
|
+
* @see {@link BusinessIdOptions} for all available configuration options
|
|
177
|
+
* @see {@link validateTaiwanBusinessId} for validation logic details
|
|
178
|
+
*/
|
|
179
|
+
export function businessId<IsRequired extends boolean = true>(options?: BusinessIdOptions<IsRequired>): BusinessIdSchema<IsRequired> {
|
|
180
|
+
const {
|
|
181
|
+
required = true,
|
|
182
|
+
transform,
|
|
183
|
+
defaultValue,
|
|
184
|
+
i18n
|
|
185
|
+
} = options ?? {}
|
|
186
|
+
|
|
187
|
+
// Set appropriate default value based on required flag
|
|
188
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
189
|
+
|
|
190
|
+
// Helper function to get custom message or fallback to default i18n
|
|
191
|
+
const getMessage = (key: keyof BusinessIdMessages, params?: Record<string, any>) => {
|
|
192
|
+
if (i18n) {
|
|
193
|
+
const currentLocale = getLocale()
|
|
194
|
+
const customMessages = i18n[currentLocale]
|
|
195
|
+
if (customMessages && customMessages[key]) {
|
|
196
|
+
const template = customMessages[key]!
|
|
197
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return t(`taiwan.businessId.${key}`, params)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Preprocessing function
|
|
204
|
+
const preprocessFn = (val: unknown) => {
|
|
205
|
+
if (val === "" || val === null || val === undefined) {
|
|
206
|
+
return actualDefaultValue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let processed = String(val).trim()
|
|
210
|
+
|
|
211
|
+
// If after trimming we have an empty string and the field is optional, return null
|
|
212
|
+
if (processed === "" && !required) {
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (transform) {
|
|
217
|
+
processed = transform(processed)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return processed
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
224
|
+
|
|
225
|
+
const schema = baseSchema.refine((val) => {
|
|
226
|
+
if (val === null) return true
|
|
227
|
+
|
|
228
|
+
// Required check
|
|
229
|
+
if (required && (val === "" || val === "null" || val === "undefined")) {
|
|
230
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (val === null) return true
|
|
234
|
+
if (!required && val === "") return true
|
|
235
|
+
|
|
236
|
+
// Taiwan Business ID format validation (8 digits + checksum)
|
|
237
|
+
if (!validateTaiwanBusinessId(val)) {
|
|
238
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return true
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
return schema as unknown as BusinessIdSchema<IsRequired>
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Utility function exported for external use
|
|
249
|
+
*
|
|
250
|
+
* @description
|
|
251
|
+
* The validation function can be used independently for business ID validation
|
|
252
|
+
* without creating a full Zod schema.
|
|
253
|
+
*
|
|
254
|
+
* @example
|
|
255
|
+
* ```typescript
|
|
256
|
+
* import { validateTaiwanBusinessId } from './business-id'
|
|
257
|
+
*
|
|
258
|
+
* // Direct validation
|
|
259
|
+
* const isValid = validateTaiwanBusinessId("12345675") // boolean
|
|
260
|
+
* ```
|
|
261
|
+
*/
|
|
262
|
+
export { validateTaiwanBusinessId }
|