@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.
Files changed (59) hide show
  1. package/.claude/settings.local.json +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +465 -97
  4. package/debug.js +21 -0
  5. package/debug.ts +16 -0
  6. package/dist/index.cjs +3127 -146
  7. package/dist/index.d.cts +3021 -25
  8. package/dist/index.d.ts +3021 -25
  9. package/dist/index.js +3081 -144
  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 +161 -25
  14. package/src/i18n/locales/zh-TW.json +165 -26
  15. package/src/index.ts +17 -7
  16. package/src/validators/common/boolean.ts +191 -0
  17. package/src/validators/common/date.ts +299 -0
  18. package/src/validators/common/datetime.ts +673 -0
  19. package/src/validators/common/email.ts +313 -0
  20. package/src/validators/common/file.ts +384 -0
  21. package/src/validators/common/id.ts +471 -0
  22. package/src/validators/common/number.ts +319 -0
  23. package/src/validators/common/password.ts +386 -0
  24. package/src/validators/common/text.ts +271 -0
  25. package/src/validators/common/time.ts +600 -0
  26. package/src/validators/common/url.ts +347 -0
  27. package/src/validators/taiwan/business-id.ts +262 -0
  28. package/src/validators/taiwan/fax.ts +327 -0
  29. package/src/validators/taiwan/mobile.ts +242 -0
  30. package/src/validators/taiwan/national-id.ts +425 -0
  31. package/src/validators/taiwan/postal-code.ts +1049 -0
  32. package/src/validators/taiwan/tel.ts +330 -0
  33. package/tests/common/boolean.test.ts +340 -92
  34. package/tests/common/date.test.ts +458 -0
  35. package/tests/common/datetime.test.ts +693 -0
  36. package/tests/common/email.test.ts +232 -60
  37. package/tests/common/file.test.ts +479 -0
  38. package/tests/common/id.test.ts +535 -0
  39. package/tests/common/number.test.ts +230 -60
  40. package/tests/common/password.test.ts +271 -44
  41. package/tests/common/text.test.ts +210 -13
  42. package/tests/common/time.test.ts +528 -0
  43. package/tests/common/url.test.ts +492 -67
  44. package/tests/taiwan/business-id.test.ts +240 -0
  45. package/tests/taiwan/fax.test.ts +463 -0
  46. package/tests/taiwan/mobile.test.ts +373 -0
  47. package/tests/taiwan/national-id.test.ts +435 -0
  48. package/tests/taiwan/postal-code.test.ts +705 -0
  49. package/tests/taiwan/tel.test.ts +467 -0
  50. package/eslint.config.mjs +0 -10
  51. package/src/common/boolean.ts +0 -36
  52. package/src/common/date.ts +0 -43
  53. package/src/common/email.ts +0 -44
  54. package/src/common/integer.ts +0 -46
  55. package/src/common/number.ts +0 -37
  56. package/src/common/password.ts +0 -33
  57. package/src/common/text.ts +0 -34
  58. package/src/common/url.ts +0 -37
  59. 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 }