schema-dsl 2.0.0 → 2.0.1

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 (145) hide show
  1. package/CHANGELOG.md +130 -113
  2. package/LICENSE +21 -21
  3. package/README.md +628 -628
  4. package/dist/{DslBuilder-DkLaOo9Q.d.ts → DslBuilder-BIgQOAXp.d.ts} +2 -0
  5. package/dist/{DslBuilder-DQDN0ZxZ.d.cts → DslBuilder-CjHTucNQ.d.cts} +2 -0
  6. package/dist/{Validator-hFWKGxir.d.ts → Validator-CllRdrY0.d.ts} +1 -1
  7. package/dist/{Validator-C7GsVQOH.d.cts → Validator-D6okG9tr.d.cts} +1 -1
  8. package/dist/index.cjs +75 -29
  9. package/dist/index.d.cts +10 -4
  10. package/dist/index.d.ts +10 -4
  11. package/dist/index.js +75 -29
  12. package/dist/plugins/custom-format.cjs +33 -17
  13. package/dist/plugins/custom-format.d.cts +1 -1
  14. package/dist/plugins/custom-format.d.ts +1 -1
  15. package/dist/plugins/custom-format.js +33 -17
  16. package/dist/plugins/custom-type-example.cjs +33 -17
  17. package/dist/plugins/custom-type-example.d.cts +1 -1
  18. package/dist/plugins/custom-type-example.d.ts +1 -1
  19. package/dist/plugins/custom-type-example.js +33 -17
  20. package/dist/plugins/custom-validator.cjs +0 -2
  21. package/dist/plugins/custom-validator.d.cts +1 -1
  22. package/dist/plugins/custom-validator.d.ts +1 -1
  23. package/dist/plugins/custom-validator.js +0 -2
  24. package/docs/FEATURE-INDEX.md +553 -553
  25. package/docs/add-custom-locale.md +496 -496
  26. package/docs/add-keyword.md +24 -24
  27. package/docs/api-reference.md +1047 -1047
  28. package/docs/api.md +13 -13
  29. package/docs/best-practices-project-structure.md +417 -417
  30. package/docs/best-practices.md +712 -712
  31. package/docs/cache-manager.md +344 -344
  32. package/docs/compile.md +45 -45
  33. package/docs/conditional-api.md +1307 -1307
  34. package/docs/custom-extensions-guide.md +339 -339
  35. package/docs/design-philosophy.md +606 -606
  36. package/docs/doc-index.md +324 -324
  37. package/docs/dsl-syntax.md +714 -714
  38. package/docs/dynamic-locale.md +608 -608
  39. package/docs/enum.md +482 -482
  40. package/docs/error-handling.md +1975 -1975
  41. package/docs/export-guide.md +501 -501
  42. package/docs/export-limitations.md +567 -567
  43. package/docs/faq.md +596 -596
  44. package/docs/frontend-i18n-guide.md +307 -307
  45. package/docs/i18n-user-guide.md +487 -487
  46. package/docs/i18n.md +476 -476
  47. package/docs/index.md +48 -48
  48. package/docs/json-schema-basics.md +40 -40
  49. package/docs/label-vs-description.md +271 -271
  50. package/docs/markdown-exporter.md +406 -406
  51. package/docs/mongodb-exporter.md +302 -302
  52. package/docs/multi-language.md +26 -26
  53. package/docs/multi-type-support.md +322 -322
  54. package/docs/mysql-exporter.md +280 -280
  55. package/docs/number-operators.md +449 -449
  56. package/docs/optional-marker-guide.md +326 -326
  57. package/docs/performance-guide.md +49 -49
  58. package/docs/plugin-system.md +381 -381
  59. package/docs/plugin-type-registration.md +34 -34
  60. package/docs/postgresql-exporter.md +311 -311
  61. package/docs/public/favicon.svg +4 -4
  62. package/docs/quick-start.md +435 -435
  63. package/docs/runtime-locale-support.md +532 -532
  64. package/docs/schema-helper.md +345 -345
  65. package/docs/schema-utils-advanced-issues.md +23 -23
  66. package/docs/schema-utils-best-practices.md +20 -20
  67. package/docs/schema-utils-chaining.md +150 -150
  68. package/docs/schema-utils.md +524 -524
  69. package/docs/security-checklist.md +20 -20
  70. package/docs/string-extensions.md +488 -488
  71. package/docs/troubleshooting.md +486 -486
  72. package/docs/type-converter.md +310 -310
  73. package/docs/type-reference.md +242 -242
  74. package/docs/typescript-guide.md +584 -584
  75. package/docs/union-type-guide.md +157 -157
  76. package/docs/union-types.md +284 -284
  77. package/docs/validate-async.md +491 -491
  78. package/docs/validate-batch.md +49 -49
  79. package/docs/validate-dsl-object-support.md +578 -578
  80. package/docs/validate.md +506 -506
  81. package/docs/validation-guide.md +502 -502
  82. package/docs/validator.md +39 -39
  83. package/package.json +131 -131
  84. package/plugins/custom-format.cjs +8 -8
  85. package/plugins/custom-type-example.cjs +8 -8
  86. package/plugins/custom-validator.cjs +8 -8
  87. package/src/adapters/DslAdapter.ts +111 -111
  88. package/src/adapters/index.ts +1 -1
  89. package/src/config/constants.ts +83 -83
  90. package/src/config/index.ts +2 -2
  91. package/src/config/patterns.ts +77 -77
  92. package/src/core/CacheManager.ts +169 -159
  93. package/src/core/ConditionalBuilder.ts +382 -382
  94. package/src/core/ConditionalRuntime.ts +27 -27
  95. package/src/core/ConditionalValidator.ts +254 -254
  96. package/src/core/DslBuilder.ts +687 -677
  97. package/src/core/ErrorCodes.ts +38 -38
  98. package/src/core/ErrorFormatter.ts +271 -271
  99. package/src/core/JSONSchemaCore.ts +65 -65
  100. package/src/core/Locale.ts +187 -187
  101. package/src/core/MessageTemplate.ts +42 -42
  102. package/src/core/ObjectDslBuilder.ts +64 -64
  103. package/src/core/PluginManager.ts +326 -326
  104. package/src/core/StringExtensions.ts +140 -140
  105. package/src/core/TemplateEngine.ts +44 -44
  106. package/src/core/Validator.ts +448 -448
  107. package/src/errors/I18nError.ts +159 -159
  108. package/src/errors/ValidationError.ts +105 -105
  109. package/src/exporters/BaseExporter.ts +60 -60
  110. package/src/exporters/MarkdownExporter.ts +305 -305
  111. package/src/exporters/MongoDBExporter.ts +126 -126
  112. package/src/exporters/MySQLExporter.ts +156 -155
  113. package/src/exporters/PostgreSQLExporter.ts +222 -222
  114. package/src/exporters/index.ts +18 -18
  115. package/src/index.ts +651 -633
  116. package/src/locales/en-US.ts +160 -160
  117. package/src/locales/es-ES.ts +160 -160
  118. package/src/locales/fr-FR.ts +160 -160
  119. package/src/locales/index.ts +103 -103
  120. package/src/locales/ja-JP.ts +160 -160
  121. package/src/locales/types.ts +156 -156
  122. package/src/locales/zh-CN.ts +160 -160
  123. package/src/parser/ConstraintParser.ts +101 -101
  124. package/src/parser/DslParser.ts +470 -470
  125. package/src/parser/SchemaCompiler.ts +66 -66
  126. package/src/parser/TypeRegistry.ts +250 -250
  127. package/src/parser/index.ts +6 -6
  128. package/src/plugins/custom-format.ts +124 -126
  129. package/src/plugins/custom-type-example.ts +106 -108
  130. package/src/plugins/custom-validator.ts +138 -140
  131. package/src/types/conditional.ts +28 -28
  132. package/src/types/config.ts +59 -59
  133. package/src/types/dsl.ts +131 -131
  134. package/src/types/error.ts +60 -60
  135. package/src/types/index.ts +17 -17
  136. package/src/types/infer.ts +127 -127
  137. package/src/types/plugin.ts +58 -58
  138. package/src/types/safe-regex.d.ts +9 -9
  139. package/src/types/schema.ts +66 -66
  140. package/src/types/validate.ts +71 -71
  141. package/src/utils/SchemaHelper.ts +196 -196
  142. package/src/utils/SchemaUtils.ts +365 -346
  143. package/src/utils/TypeConverter.ts +215 -215
  144. package/src/utils/index.ts +10 -10
  145. package/src/validators/CustomKeywords.ts +477 -477
@@ -1,477 +1,477 @@
1
- import type { Ajv, ErrorObject } from 'ajv'
2
- import safeRegex from 'safe-regex'
3
- import { Locale } from '../core/Locale.js'
4
-
5
-
6
- // AJV DataValidateFunction compatible type
7
- type ValidateFnWithErrors = ((schema: unknown, data: unknown, parentSchema?: unknown) => boolean) & {
8
- errors?: Partial<ErrorObject>[]
9
- }
10
-
11
- /**
12
- * CustomKeywords — AJV custom keyword registrar
13
- *
14
- * Fixes:
15
- * CK-01: internally uses getMessageText() to obtain strings, avoiding v1 compat objects
16
- * that serialized as "[object Object]"
17
- * CK-02: regex keyword error messages use locale keys instead of concatenating raw messages
18
- * CK-Y04: exactLength uses Unicode code-point counting ([...str].length) instead of
19
- * str.length, correctly handling emoji and multi-byte characters
20
- */
21
- export class CustomKeywords {
22
- /**
23
- * Register all custom keywords on an AJV instance
24
- */
25
- static registerAll(ajv: Ajv): void {
26
- CustomKeywords.registerRegexKeyword(ajv)
27
- CustomKeywords.registerFunctionKeyword(ajv)
28
- CustomKeywords.registerCustomValidatorsKeyword(ajv)
29
- CustomKeywords.registerMetadataKeywords(ajv)
30
- CustomKeywords.registerStringValidators(ajv)
31
- CustomKeywords.registerNumberValidators(ajv)
32
- CustomKeywords.registerObjectValidators(ajv)
33
- CustomKeywords.registerArrayValidators(ajv)
34
- CustomKeywords.registerDateValidators(ajv)
35
- }
36
-
37
- // ─── Metadata keywords ──────────────────────────────────────────────────
38
-
39
- static registerMetadataKeywords(ajv: Ajv): void {
40
- ajv.addKeyword({ keyword: '_label', metaSchema: { type: 'string' } })
41
- ajv.addKeyword({ keyword: '_customMessages', metaSchema: { type: 'object' } })
42
- ajv.addKeyword({ keyword: '_description', metaSchema: { type: 'string' } })
43
- ajv.addKeyword({ keyword: '_whenConditions', metaSchema: { type: 'array' } })
44
- ajv.addKeyword({ keyword: '_required', metaSchema: { type: 'boolean' } })
45
- // Conditional schema marker: prevents AJV strict mode from throwing an unknown-keyword error
46
- ajv.addKeyword({ keyword: '_isConditional', metaSchema: { type: 'boolean' } })
47
- ajv.addKeyword({ keyword: '_runtimeOnlyConditional', metaSchema: { type: 'boolean' } })
48
- ajv.addKeyword({ keyword: 'conditions' })
49
- ajv.addKeyword({ keyword: '_evaluateCondition' })
50
- }
51
-
52
- // ─── _customValidators ──────────────────────────────────────────────────
53
-
54
- static registerCustomValidatorsKeyword(ajv: Ajv): void {
55
- const validate: ValidateFnWithErrors = (validators: unknown, data: unknown): boolean => {
56
- if (!Array.isArray(validators)) return true
57
-
58
- for (const validator of validators as unknown[]) {
59
- if (typeof validator !== 'function') continue
60
- try {
61
- const result = (validator as (d: unknown) => unknown)(data)
62
-
63
- if (result instanceof Promise) {
64
- // BC-6: async validators are not supported in the synchronous AJV validate() path.
65
- // Return an explicit error so callers know to use validateAsync() instead.
66
- validate.errors = [{
67
- keyword: '_customValidators',
68
- message: 'Async validation not supported in sync validate(). Use validateAsync() instead.',
69
- params: {},
70
- }]
71
- return false
72
- }
73
-
74
- if (result === false) {
75
- const msg = Locale.getMessageText('CUSTOM_VALIDATION_FAILED')
76
- validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
77
- return false
78
- }
79
- if (typeof result === 'string') {
80
- validate.errors = [{ keyword: '_customValidators', message: result, params: {} }]
81
- return false
82
- }
83
- if (result !== null && typeof result === 'object' && (result as Record<string, unknown>)['error']) {
84
- const msg = String((result as Record<string, unknown>)['message'] ?? Locale.getMessageText('CUSTOM_VALIDATION_FAILED'))
85
- validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
86
- return false
87
- }
88
- } catch (error) {
89
- const msg = error instanceof Error ? error.message : String(error)
90
- validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
91
- return false
92
- }
93
- }
94
- return true
95
- }
96
-
97
- ajv.addKeyword({ keyword: '_customValidators', validate, errors: true })
98
- }
99
-
100
- // ─── regex ──────────────────────────────────────────────────────────────
101
-
102
- // Detect potentially catastrophic patterns via a dedicated regex safety analyzer
103
- private static _isUnsafePattern(pattern: string | RegExp): boolean {
104
- return !safeRegex(pattern)
105
- }
106
-
107
- static registerRegexKeyword(ajv: Ajv): void {
108
- const validate: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
109
- const patternStr = String(schema)
110
- try {
111
- const regex = new RegExp(patternStr)
112
- if (CustomKeywords._isUnsafePattern(regex)) {
113
- validate.errors = [{
114
- keyword: 'regex',
115
- message: Locale.getMessageText('string.pattern'),
116
- params: { pattern: patternStr, reason: 'unsafe regex pattern' },
117
- }]
118
- return false
119
- }
120
- if (regex.test(String(data))) return true
121
- // CK-02 fix: use locale key instead of concatenating raw error message
122
- validate.errors = [{
123
- keyword: 'regex',
124
- message: Locale.getMessageText('string.pattern'),
125
- params: { pattern: schema },
126
- }]
127
- return false
128
- } catch (error) {
129
- // CK-02 fix: invalid regex also uses locale key
130
- validate.errors = [{
131
- keyword: 'regex',
132
- message: Locale.getMessageText('string.pattern'),
133
- params: { error: error instanceof Error ? error.message : String(error) },
134
- }]
135
- return false
136
- }
137
- }
138
-
139
- ajv.addKeyword({ keyword: 'regex', type: 'string', schemaType: 'string', validate, errors: true })
140
- }
141
-
142
- // ─── validate (function validator) ──────────────────────────────────────
143
-
144
- static registerFunctionKeyword(ajv: Ajv): void {
145
- const validate: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
146
- if (typeof schema !== 'function') {
147
- validate.errors = [{
148
- keyword: 'validate',
149
- message: Locale.getMessageText('VALIDATE_MUST_BE_FUNCTION'),
150
- params: {},
151
- }]
152
- return false
153
- }
154
-
155
- try {
156
- const result = (schema as (d: unknown) => unknown)(data)
157
- if (typeof result === 'boolean') return result
158
- if (result !== null && typeof result === 'object') {
159
- const res = result as Record<string, unknown>
160
- if (typeof res['valid'] === 'boolean') {
161
- if (!res['valid'] && res['message']) {
162
- validate.errors = [{
163
- keyword: 'validate',
164
- message: String(res['message']),
165
- params: {},
166
- }]
167
- }
168
- return res['valid'] as boolean
169
- }
170
- }
171
- return true
172
- } catch (error) {
173
- validate.errors = [{
174
- keyword: 'validate',
175
- message: error instanceof Error ? error.message : String(error),
176
- params: {},
177
- }]
178
- return false
179
- }
180
- }
181
-
182
- ajv.addKeyword({ keyword: 'validate', validate, errors: true })
183
- }
184
-
185
- // ─── String validators ───────────────────────────────────────────────────
186
-
187
- static registerStringValidators(ajv: Ajv): void {
188
- // exactLength — exact string length (CK-Y04 fix: Unicode code-point counting)
189
- const exactLength: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
190
- // CK-Y04: use spread iterator for counting — correctly handles emoji / multi-byte Unicode
191
- const codePointLength = [...String(data)].length
192
- if (codePointLength !== Number(schema)) {
193
- exactLength.errors = [{
194
- keyword: 'exactLength',
195
- message: Locale.getMessageText('string.length'),
196
- params: { limit: schema },
197
- }]
198
- return false
199
- }
200
- return true
201
- }
202
- ajv.addKeyword({ keyword: 'exactLength', type: 'string', schemaType: 'number', validate: exactLength, errors: true })
203
-
204
- // alphanum
205
- const alphanum: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
206
- if (schema && !/^[a-zA-Z0-9]*$/.test(String(data))) {
207
- alphanum.errors = [{ keyword: 'alphanum', message: Locale.getMessageText('string.alphanum'), params: {} }]
208
- return false
209
- }
210
- return true
211
- }
212
- ajv.addKeyword({ keyword: 'alphanum', type: 'string', schemaType: 'boolean', validate: alphanum, errors: true })
213
-
214
- // trim
215
- const trim: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
216
- const str = String(data)
217
- if (schema && str !== str.trim()) {
218
- trim.errors = [{ keyword: 'trim', message: Locale.getMessageText('string.trim'), params: {} }]
219
- return false
220
- }
221
- return true
222
- }
223
- ajv.addKeyword({ keyword: 'trim', type: 'string', schemaType: 'boolean', validate: trim, errors: true })
224
-
225
- // lowercase
226
- const lowercase: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
227
- const str = String(data)
228
- if (schema && str !== str.toLowerCase()) {
229
- lowercase.errors = [{ keyword: 'lowercase', message: Locale.getMessageText('string.lowercase'), params: {} }]
230
- return false
231
- }
232
- return true
233
- }
234
- ajv.addKeyword({ keyword: 'lowercase', type: 'string', schemaType: 'boolean', validate: lowercase, errors: true })
235
-
236
- // uppercase
237
- const uppercase: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
238
- const str = String(data)
239
- if (schema && str !== str.toUpperCase()) {
240
- uppercase.errors = [{ keyword: 'uppercase', message: Locale.getMessageText('string.uppercase'), params: {} }]
241
- return false
242
- }
243
- return true
244
- }
245
- ajv.addKeyword({ keyword: 'uppercase', type: 'string', schemaType: 'boolean', validate: uppercase, errors: true })
246
-
247
- // jsonString
248
- const jsonString: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
249
- if (schema) {
250
- try {
251
- JSON.parse(String(data))
252
- } catch {
253
- jsonString.errors = [{ keyword: 'jsonString', message: Locale.getMessageText('pattern.json'), params: {} }]
254
- return false
255
- }
256
- }
257
- return true
258
- }
259
- ajv.addKeyword({ keyword: 'jsonString', type: 'string', schemaType: 'boolean', validate: jsonString, errors: true })
260
- }
261
-
262
- // ─── Number validators ───────────────────────────────────────────────────
263
-
264
- static registerNumberValidators(ajv: Ajv): void {
265
- // precision — decimal place limit
266
- const precision: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
267
- const n = data as number
268
- const limit = Number(schema)
269
- const factor = Math.pow(10, limit)
270
- const shifted = n * factor
271
- // Epsilon-based check handles floating-point artifacts (e.g. 0.1+0.2 = 0.30000000000000004)
272
- if (Math.abs(shifted - Math.round(shifted)) > 1e-10) {
273
- precision.errors = [{ keyword: 'precision', message: Locale.getMessageText('number.precision'), params: { limit: schema } }]
274
- return false
275
- }
276
- return true
277
- }
278
- ajv.addKeyword({ keyword: 'precision', type: 'number', schemaType: 'number', validate: precision, errors: true })
279
-
280
- // port — port number validation (1-65535)
281
- const port: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
282
- const num = data as number
283
- if (schema && (!Number.isInteger(num) || num < 1 || num > 65535)) {
284
- port.errors = [{ keyword: 'port', message: Locale.getMessageText('number.port'), params: {} }]
285
- return false
286
- }
287
- return true
288
- }
289
- ajv.addKeyword({ keyword: 'port', type: ['integer', 'number'], schemaType: 'boolean', validate: port, errors: true })
290
- }
291
-
292
- // ─── Object validators ──────────────────────────────────────────────────
293
-
294
- static registerObjectValidators(ajv: Ajv): void {
295
- // requiredAll — require all defined properties to be present
296
- const requiredAll: ValidateFnWithErrors = (schema: unknown, data: unknown, parentSchema?: unknown): boolean => {
297
- if (!schema) return true
298
- const props = ((parentSchema as Record<string, unknown>)?.['properties'] as Record<string, unknown>) ?? {}
299
- const missingKeys = Object.keys(props).filter(k => !(k in (data as Record<string, unknown>)))
300
- if (missingKeys.length > 0) {
301
- requiredAll.errors = [{
302
- keyword: 'requiredAll',
303
- message: Locale.getMessageText('object.missing'),
304
- params: { missing: missingKeys },
305
- }]
306
- return false
307
- }
308
- return true
309
- }
310
- ajv.addKeyword({ keyword: 'requiredAll', type: 'object', schemaType: 'boolean', validate: requiredAll, errors: true })
311
-
312
- // strictSchema — disallow extra properties
313
- const strictSchema: ValidateFnWithErrors = (schema: unknown, data: unknown, parentSchema?: unknown): boolean => {
314
- if (!schema) return true
315
- const props = ((parentSchema as Record<string, unknown>)?.['properties'] as Record<string, unknown>) ?? {}
316
- const allowedKeys = Object.keys(props)
317
- const extraKeys = Object.keys(data as Record<string, unknown>).filter(k => !allowedKeys.includes(k))
318
- if (extraKeys.length > 0) {
319
- strictSchema.errors = [{
320
- keyword: 'strictSchema',
321
- message: Locale.getMessageText('object.schema'),
322
- params: { extra: extraKeys },
323
- }]
324
- return false
325
- }
326
- return true
327
- }
328
- ajv.addKeyword({ keyword: 'strictSchema', type: 'object', schemaType: 'boolean', validate: strictSchema, errors: true })
329
- }
330
-
331
- // ─── Array validators ───────────────────────────────────────────────────
332
-
333
- private static _deepEqual(a: unknown, b: unknown): boolean {
334
- if (a === b) return true
335
- if (a === null || b === null || typeof a !== typeof b) return false
336
- if (typeof a !== 'object') return false
337
- if (Array.isArray(a) !== Array.isArray(b)) return false
338
- if (Array.isArray(a)) {
339
- if ((a as unknown[]).length !== (b as unknown[]).length) return false
340
- return (a as unknown[]).every((item, i) => CustomKeywords._deepEqual(item, (b as unknown[])[i]))
341
- }
342
- const aKeys = Object.keys(a as object).sort()
343
- const bKeys = Object.keys(b as object).sort()
344
- if (aKeys.length !== bKeys.length) return false
345
- return aKeys.every(k =>
346
- CustomKeywords._deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])
347
- )
348
- }
349
-
350
- static registerArrayValidators(ajv: Ajv): void {
351
- // noSparse — disallow sparse arrays
352
- const noSparse: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
353
- const arr = data as unknown[]
354
- if (schema) {
355
- for (let i = 0; i < arr.length; i++) {
356
- if (!(i in arr)) {
357
- noSparse.errors = [{
358
- keyword: 'noSparse',
359
- message: Locale.getMessageText('array.sparse'),
360
- params: { index: i },
361
- }]
362
- return false
363
- }
364
- }
365
- }
366
- return true
367
- }
368
- ajv.addKeyword({ keyword: 'noSparse', type: 'array', schemaType: 'boolean', validate: noSparse, errors: true })
369
-
370
- // includesRequired — must include specified elements
371
- const includesRequired: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
372
- if (!Array.isArray(schema) || schema.length === 0) return true
373
- const arr = data as unknown[]
374
- const missing = (schema as unknown[]).filter(required => {
375
- return !arr.some(item => {
376
- if (typeof required === 'object' && required !== null) {
377
- return CustomKeywords._deepEqual(item, required)
378
- }
379
- return item === required
380
- })
381
- })
382
- if (missing.length > 0) {
383
- includesRequired.errors = [{
384
- keyword: 'includesRequired',
385
- message: Locale.getMessageText('array.includesRequired'),
386
- params: { missing },
387
- }]
388
- return false
389
- }
390
- return true
391
- }
392
- ajv.addKeyword({ keyword: 'includesRequired', type: 'array', schemaType: 'array', validate: includesRequired, errors: true })
393
- }
394
-
395
- // ─── Date validators ────────────────────────────────────────────────────
396
-
397
- static registerDateValidators(ajv: Ajv): void {
398
- const DATE_FORMATS: Record<string, RegExp> = {
399
- 'YYYY-MM-DD': /^\d{4}-\d{2}-\d{2}$/,
400
- 'YYYY/MM/DD': /^\d{4}\/\d{2}\/\d{2}$/,
401
- 'DD-MM-YYYY': /^\d{2}-\d{2}-\d{4}$/,
402
- 'DD/MM/YYYY': /^\d{2}\/\d{2}\/\d{4}$/,
403
- 'ISO8601': /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/,
404
- }
405
-
406
- // dateFormat
407
- const dateFormat: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
408
- const fmt = String(schema)
409
- const pattern = DATE_FORMATS[fmt]
410
- const str = String(data)
411
- if (!pattern || !pattern.test(str)) {
412
- dateFormat.errors = [{
413
- keyword: 'dateFormat',
414
- message: Locale.getMessageText('date.format'),
415
- params: { format: schema },
416
- }]
417
- return false
418
- }
419
- // Calendar validity: extract components based on format and verify via Date
420
- const sep = /[-/]/.exec(str)?.[0] ?? '-'
421
- const parts = str.split(sep)
422
- let y: number, m: number, dd: number
423
- if (fmt === 'DD-MM-YYYY' || fmt === 'DD/MM/YYYY') {
424
- [dd, m, y] = [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)]
425
- } else if (fmt === 'ISO8601') {
426
- const d2 = new Date(str)
427
- if (isNaN(d2.getTime())) {
428
- dateFormat.errors = [{ keyword: 'dateFormat', message: Locale.getMessageText('date.format'), params: { format: schema } }]
429
- return false
430
- }
431
- return true
432
- } else {
433
- [y, m, dd] = [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)]
434
- }
435
- // Verify the date exists (e.g., reject 2024-13-99, 2024-02-31)
436
- const probe = new Date(y, m - 1, dd)
437
- if (probe.getFullYear() !== y || probe.getMonth() !== m - 1 || probe.getDate() !== dd) {
438
- dateFormat.errors = [{ keyword: 'dateFormat', message: Locale.getMessageText('date.format'), params: { format: schema } }]
439
- return false
440
- }
441
- return true
442
- }
443
- ajv.addKeyword({ keyword: 'dateFormat', type: 'string', schemaType: 'string', validate: dateFormat, errors: true })
444
-
445
- // dateGreater
446
- const dateGreater: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
447
- const dataDate = new Date(String(data))
448
- const compareDate = new Date(String(schema))
449
- if (isNaN(dataDate.getTime()) || isNaN(compareDate.getTime()) || dataDate <= compareDate) {
450
- dateGreater.errors = [{
451
- keyword: 'dateGreater',
452
- message: Locale.getMessageText('date.greater'),
453
- params: { limit: schema },
454
- }]
455
- return false
456
- }
457
- return true
458
- }
459
- ajv.addKeyword({ keyword: 'dateGreater', type: 'string', schemaType: 'string', validate: dateGreater, errors: true })
460
-
461
- // dateLess
462
- const dateLess: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
463
- const dataDate = new Date(String(data))
464
- const compareDate = new Date(String(schema))
465
- if (isNaN(dataDate.getTime()) || isNaN(compareDate.getTime()) || dataDate >= compareDate) {
466
- dateLess.errors = [{
467
- keyword: 'dateLess',
468
- message: Locale.getMessageText('date.less'),
469
- params: { limit: schema },
470
- }]
471
- return false
472
- }
473
- return true
474
- }
475
- ajv.addKeyword({ keyword: 'dateLess', type: 'string', schemaType: 'string', validate: dateLess, errors: true })
476
- }
477
- }
1
+ import type { Ajv, ErrorObject } from 'ajv'
2
+ import safeRegex from 'safe-regex'
3
+ import { Locale } from '../core/Locale.js'
4
+
5
+
6
+ // AJV DataValidateFunction compatible type
7
+ type ValidateFnWithErrors = ((schema: unknown, data: unknown, parentSchema?: unknown) => boolean) & {
8
+ errors?: Partial<ErrorObject>[]
9
+ }
10
+
11
+ /**
12
+ * CustomKeywords — AJV custom keyword registrar
13
+ *
14
+ * Fixes:
15
+ * CK-01: internally uses getMessageText() to obtain strings, avoiding v1 compat objects
16
+ * that serialized as "[object Object]"
17
+ * CK-02: regex keyword error messages use locale keys instead of concatenating raw messages
18
+ * CK-Y04: exactLength uses Unicode code-point counting ([...str].length) instead of
19
+ * str.length, correctly handling emoji and multi-byte characters
20
+ */
21
+ export class CustomKeywords {
22
+ /**
23
+ * Register all custom keywords on an AJV instance
24
+ */
25
+ static registerAll(ajv: Ajv): void {
26
+ CustomKeywords.registerRegexKeyword(ajv)
27
+ CustomKeywords.registerFunctionKeyword(ajv)
28
+ CustomKeywords.registerCustomValidatorsKeyword(ajv)
29
+ CustomKeywords.registerMetadataKeywords(ajv)
30
+ CustomKeywords.registerStringValidators(ajv)
31
+ CustomKeywords.registerNumberValidators(ajv)
32
+ CustomKeywords.registerObjectValidators(ajv)
33
+ CustomKeywords.registerArrayValidators(ajv)
34
+ CustomKeywords.registerDateValidators(ajv)
35
+ }
36
+
37
+ // ─── Metadata keywords ──────────────────────────────────────────────────
38
+
39
+ static registerMetadataKeywords(ajv: Ajv): void {
40
+ ajv.addKeyword({ keyword: '_label', metaSchema: { type: 'string' } })
41
+ ajv.addKeyword({ keyword: '_customMessages', metaSchema: { type: 'object' } })
42
+ ajv.addKeyword({ keyword: '_description', metaSchema: { type: 'string' } })
43
+ ajv.addKeyword({ keyword: '_whenConditions', metaSchema: { type: 'array' } })
44
+ ajv.addKeyword({ keyword: '_required', metaSchema: { type: 'boolean' } })
45
+ // Conditional schema marker: prevents AJV strict mode from throwing an unknown-keyword error
46
+ ajv.addKeyword({ keyword: '_isConditional', metaSchema: { type: 'boolean' } })
47
+ ajv.addKeyword({ keyword: '_runtimeOnlyConditional', metaSchema: { type: 'boolean' } })
48
+ ajv.addKeyword({ keyword: 'conditions' })
49
+ ajv.addKeyword({ keyword: '_evaluateCondition' })
50
+ }
51
+
52
+ // ─── _customValidators ──────────────────────────────────────────────────
53
+
54
+ static registerCustomValidatorsKeyword(ajv: Ajv): void {
55
+ const validate: ValidateFnWithErrors = (validators: unknown, data: unknown): boolean => {
56
+ if (!Array.isArray(validators)) return true
57
+
58
+ for (const validator of validators as unknown[]) {
59
+ if (typeof validator !== 'function') continue
60
+ try {
61
+ const result = (validator as (d: unknown) => unknown)(data)
62
+
63
+ if (result instanceof Promise) {
64
+ // BC-6: async validators are not supported in the synchronous AJV validate() path.
65
+ // Return an explicit error so callers know to use validateAsync() instead.
66
+ validate.errors = [{
67
+ keyword: '_customValidators',
68
+ message: 'Async validation not supported in sync validate(). Use validateAsync() instead.',
69
+ params: {},
70
+ }]
71
+ return false
72
+ }
73
+
74
+ if (result === false) {
75
+ const msg = Locale.getMessageText('CUSTOM_VALIDATION_FAILED')
76
+ validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
77
+ return false
78
+ }
79
+ if (typeof result === 'string') {
80
+ validate.errors = [{ keyword: '_customValidators', message: result, params: {} }]
81
+ return false
82
+ }
83
+ if (result !== null && typeof result === 'object' && (result as Record<string, unknown>)['error']) {
84
+ const msg = String((result as Record<string, unknown>)['message'] ?? Locale.getMessageText('CUSTOM_VALIDATION_FAILED'))
85
+ validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
86
+ return false
87
+ }
88
+ } catch (error) {
89
+ const msg = error instanceof Error ? error.message : String(error)
90
+ validate.errors = [{ keyword: '_customValidators', message: msg, params: {} }]
91
+ return false
92
+ }
93
+ }
94
+ return true
95
+ }
96
+
97
+ ajv.addKeyword({ keyword: '_customValidators', validate, errors: true })
98
+ }
99
+
100
+ // ─── regex ──────────────────────────────────────────────────────────────
101
+
102
+ // Detect potentially catastrophic patterns via a dedicated regex safety analyzer
103
+ private static _isUnsafePattern(pattern: string | RegExp): boolean {
104
+ return !safeRegex(pattern)
105
+ }
106
+
107
+ static registerRegexKeyword(ajv: Ajv): void {
108
+ const validate: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
109
+ const patternStr = String(schema)
110
+ try {
111
+ const regex = new RegExp(patternStr)
112
+ if (CustomKeywords._isUnsafePattern(regex)) {
113
+ validate.errors = [{
114
+ keyword: 'regex',
115
+ message: Locale.getMessageText('string.pattern'),
116
+ params: { pattern: patternStr, reason: 'unsafe regex pattern' },
117
+ }]
118
+ return false
119
+ }
120
+ if (regex.test(String(data))) return true
121
+ // CK-02 fix: use locale key instead of concatenating raw error message
122
+ validate.errors = [{
123
+ keyword: 'regex',
124
+ message: Locale.getMessageText('string.pattern'),
125
+ params: { pattern: schema },
126
+ }]
127
+ return false
128
+ } catch (error) {
129
+ // CK-02 fix: invalid regex also uses locale key
130
+ validate.errors = [{
131
+ keyword: 'regex',
132
+ message: Locale.getMessageText('string.pattern'),
133
+ params: { error: error instanceof Error ? error.message : String(error) },
134
+ }]
135
+ return false
136
+ }
137
+ }
138
+
139
+ ajv.addKeyword({ keyword: 'regex', type: 'string', schemaType: 'string', validate, errors: true })
140
+ }
141
+
142
+ // ─── validate (function validator) ──────────────────────────────────────
143
+
144
+ static registerFunctionKeyword(ajv: Ajv): void {
145
+ const validate: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
146
+ if (typeof schema !== 'function') {
147
+ validate.errors = [{
148
+ keyword: 'validate',
149
+ message: Locale.getMessageText('VALIDATE_MUST_BE_FUNCTION'),
150
+ params: {},
151
+ }]
152
+ return false
153
+ }
154
+
155
+ try {
156
+ const result = (schema as (d: unknown) => unknown)(data)
157
+ if (typeof result === 'boolean') return result
158
+ if (result !== null && typeof result === 'object') {
159
+ const res = result as Record<string, unknown>
160
+ if (typeof res['valid'] === 'boolean') {
161
+ if (!res['valid'] && res['message']) {
162
+ validate.errors = [{
163
+ keyword: 'validate',
164
+ message: String(res['message']),
165
+ params: {},
166
+ }]
167
+ }
168
+ return res['valid'] as boolean
169
+ }
170
+ }
171
+ return true
172
+ } catch (error) {
173
+ validate.errors = [{
174
+ keyword: 'validate',
175
+ message: error instanceof Error ? error.message : String(error),
176
+ params: {},
177
+ }]
178
+ return false
179
+ }
180
+ }
181
+
182
+ ajv.addKeyword({ keyword: 'validate', validate, errors: true })
183
+ }
184
+
185
+ // ─── String validators ───────────────────────────────────────────────────
186
+
187
+ static registerStringValidators(ajv: Ajv): void {
188
+ // exactLength — exact string length (CK-Y04 fix: Unicode code-point counting)
189
+ const exactLength: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
190
+ // CK-Y04: use spread iterator for counting — correctly handles emoji / multi-byte Unicode
191
+ const codePointLength = [...String(data)].length
192
+ if (codePointLength !== Number(schema)) {
193
+ exactLength.errors = [{
194
+ keyword: 'exactLength',
195
+ message: Locale.getMessageText('string.length'),
196
+ params: { limit: schema },
197
+ }]
198
+ return false
199
+ }
200
+ return true
201
+ }
202
+ ajv.addKeyword({ keyword: 'exactLength', type: 'string', schemaType: 'number', validate: exactLength, errors: true })
203
+
204
+ // alphanum
205
+ const alphanum: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
206
+ if (schema && !/^[a-zA-Z0-9]*$/.test(String(data))) {
207
+ alphanum.errors = [{ keyword: 'alphanum', message: Locale.getMessageText('string.alphanum'), params: {} }]
208
+ return false
209
+ }
210
+ return true
211
+ }
212
+ ajv.addKeyword({ keyword: 'alphanum', type: 'string', schemaType: 'boolean', validate: alphanum, errors: true })
213
+
214
+ // trim
215
+ const trim: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
216
+ const str = String(data)
217
+ if (schema && str !== str.trim()) {
218
+ trim.errors = [{ keyword: 'trim', message: Locale.getMessageText('string.trim'), params: {} }]
219
+ return false
220
+ }
221
+ return true
222
+ }
223
+ ajv.addKeyword({ keyword: 'trim', type: 'string', schemaType: 'boolean', validate: trim, errors: true })
224
+
225
+ // lowercase
226
+ const lowercase: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
227
+ const str = String(data)
228
+ if (schema && str !== str.toLowerCase()) {
229
+ lowercase.errors = [{ keyword: 'lowercase', message: Locale.getMessageText('string.lowercase'), params: {} }]
230
+ return false
231
+ }
232
+ return true
233
+ }
234
+ ajv.addKeyword({ keyword: 'lowercase', type: 'string', schemaType: 'boolean', validate: lowercase, errors: true })
235
+
236
+ // uppercase
237
+ const uppercase: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
238
+ const str = String(data)
239
+ if (schema && str !== str.toUpperCase()) {
240
+ uppercase.errors = [{ keyword: 'uppercase', message: Locale.getMessageText('string.uppercase'), params: {} }]
241
+ return false
242
+ }
243
+ return true
244
+ }
245
+ ajv.addKeyword({ keyword: 'uppercase', type: 'string', schemaType: 'boolean', validate: uppercase, errors: true })
246
+
247
+ // jsonString
248
+ const jsonString: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
249
+ if (schema) {
250
+ try {
251
+ JSON.parse(String(data))
252
+ } catch {
253
+ jsonString.errors = [{ keyword: 'jsonString', message: Locale.getMessageText('pattern.json'), params: {} }]
254
+ return false
255
+ }
256
+ }
257
+ return true
258
+ }
259
+ ajv.addKeyword({ keyword: 'jsonString', type: 'string', schemaType: 'boolean', validate: jsonString, errors: true })
260
+ }
261
+
262
+ // ─── Number validators ───────────────────────────────────────────────────
263
+
264
+ static registerNumberValidators(ajv: Ajv): void {
265
+ // precision — decimal place limit
266
+ const precision: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
267
+ const n = data as number
268
+ const limit = Number(schema)
269
+ const factor = Math.pow(10, limit)
270
+ const shifted = n * factor
271
+ // Epsilon-based check handles floating-point artifacts (e.g. 0.1+0.2 = 0.30000000000000004)
272
+ if (Math.abs(shifted - Math.round(shifted)) > 1e-10) {
273
+ precision.errors = [{ keyword: 'precision', message: Locale.getMessageText('number.precision'), params: { limit: schema } }]
274
+ return false
275
+ }
276
+ return true
277
+ }
278
+ ajv.addKeyword({ keyword: 'precision', type: 'number', schemaType: 'number', validate: precision, errors: true })
279
+
280
+ // port — port number validation (1-65535)
281
+ const port: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
282
+ const num = data as number
283
+ if (schema && (!Number.isInteger(num) || num < 1 || num > 65535)) {
284
+ port.errors = [{ keyword: 'port', message: Locale.getMessageText('number.port'), params: {} }]
285
+ return false
286
+ }
287
+ return true
288
+ }
289
+ ajv.addKeyword({ keyword: 'port', type: ['integer', 'number'], schemaType: 'boolean', validate: port, errors: true })
290
+ }
291
+
292
+ // ─── Object validators ──────────────────────────────────────────────────
293
+
294
+ static registerObjectValidators(ajv: Ajv): void {
295
+ // requiredAll — require all defined properties to be present
296
+ const requiredAll: ValidateFnWithErrors = (schema: unknown, data: unknown, parentSchema?: unknown): boolean => {
297
+ if (!schema) return true
298
+ const props = ((parentSchema as Record<string, unknown>)?.['properties'] as Record<string, unknown>) ?? {}
299
+ const missingKeys = Object.keys(props).filter(k => !(k in (data as Record<string, unknown>)))
300
+ if (missingKeys.length > 0) {
301
+ requiredAll.errors = [{
302
+ keyword: 'requiredAll',
303
+ message: Locale.getMessageText('object.missing'),
304
+ params: { missing: missingKeys },
305
+ }]
306
+ return false
307
+ }
308
+ return true
309
+ }
310
+ ajv.addKeyword({ keyword: 'requiredAll', type: 'object', schemaType: 'boolean', validate: requiredAll, errors: true })
311
+
312
+ // strictSchema — disallow extra properties
313
+ const strictSchema: ValidateFnWithErrors = (schema: unknown, data: unknown, parentSchema?: unknown): boolean => {
314
+ if (!schema) return true
315
+ const props = ((parentSchema as Record<string, unknown>)?.['properties'] as Record<string, unknown>) ?? {}
316
+ const allowedKeys = Object.keys(props)
317
+ const extraKeys = Object.keys(data as Record<string, unknown>).filter(k => !allowedKeys.includes(k))
318
+ if (extraKeys.length > 0) {
319
+ strictSchema.errors = [{
320
+ keyword: 'strictSchema',
321
+ message: Locale.getMessageText('object.schema'),
322
+ params: { extra: extraKeys },
323
+ }]
324
+ return false
325
+ }
326
+ return true
327
+ }
328
+ ajv.addKeyword({ keyword: 'strictSchema', type: 'object', schemaType: 'boolean', validate: strictSchema, errors: true })
329
+ }
330
+
331
+ // ─── Array validators ───────────────────────────────────────────────────
332
+
333
+ private static _deepEqual(a: unknown, b: unknown): boolean {
334
+ if (a === b) return true
335
+ if (a === null || b === null || typeof a !== typeof b) return false
336
+ if (typeof a !== 'object') return false
337
+ if (Array.isArray(a) !== Array.isArray(b)) return false
338
+ if (Array.isArray(a)) {
339
+ if ((a as unknown[]).length !== (b as unknown[]).length) return false
340
+ return (a as unknown[]).every((item, i) => CustomKeywords._deepEqual(item, (b as unknown[])[i]))
341
+ }
342
+ const aKeys = Object.keys(a as object).sort()
343
+ const bKeys = Object.keys(b as object).sort()
344
+ if (aKeys.length !== bKeys.length) return false
345
+ return aKeys.every(k =>
346
+ CustomKeywords._deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])
347
+ )
348
+ }
349
+
350
+ static registerArrayValidators(ajv: Ajv): void {
351
+ // noSparse — disallow sparse arrays
352
+ const noSparse: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
353
+ const arr = data as unknown[]
354
+ if (schema) {
355
+ for (let i = 0; i < arr.length; i++) {
356
+ if (!(i in arr)) {
357
+ noSparse.errors = [{
358
+ keyword: 'noSparse',
359
+ message: Locale.getMessageText('array.sparse'),
360
+ params: { index: i },
361
+ }]
362
+ return false
363
+ }
364
+ }
365
+ }
366
+ return true
367
+ }
368
+ ajv.addKeyword({ keyword: 'noSparse', type: 'array', schemaType: 'boolean', validate: noSparse, errors: true })
369
+
370
+ // includesRequired — must include specified elements
371
+ const includesRequired: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
372
+ if (!Array.isArray(schema) || schema.length === 0) return true
373
+ const arr = data as unknown[]
374
+ const missing = (schema as unknown[]).filter(required => {
375
+ return !arr.some(item => {
376
+ if (typeof required === 'object' && required !== null) {
377
+ return CustomKeywords._deepEqual(item, required)
378
+ }
379
+ return item === required
380
+ })
381
+ })
382
+ if (missing.length > 0) {
383
+ includesRequired.errors = [{
384
+ keyword: 'includesRequired',
385
+ message: Locale.getMessageText('array.includesRequired'),
386
+ params: { missing },
387
+ }]
388
+ return false
389
+ }
390
+ return true
391
+ }
392
+ ajv.addKeyword({ keyword: 'includesRequired', type: 'array', schemaType: 'array', validate: includesRequired, errors: true })
393
+ }
394
+
395
+ // ─── Date validators ────────────────────────────────────────────────────
396
+
397
+ static registerDateValidators(ajv: Ajv): void {
398
+ const DATE_FORMATS: Record<string, RegExp> = {
399
+ 'YYYY-MM-DD': /^\d{4}-\d{2}-\d{2}$/,
400
+ 'YYYY/MM/DD': /^\d{4}\/\d{2}\/\d{2}$/,
401
+ 'DD-MM-YYYY': /^\d{2}-\d{2}-\d{4}$/,
402
+ 'DD/MM/YYYY': /^\d{2}\/\d{2}\/\d{4}$/,
403
+ 'ISO8601': /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/,
404
+ }
405
+
406
+ // dateFormat
407
+ const dateFormat: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
408
+ const fmt = String(schema)
409
+ const pattern = DATE_FORMATS[fmt]
410
+ const str = String(data)
411
+ if (!pattern || !pattern.test(str)) {
412
+ dateFormat.errors = [{
413
+ keyword: 'dateFormat',
414
+ message: Locale.getMessageText('date.format'),
415
+ params: { format: schema },
416
+ }]
417
+ return false
418
+ }
419
+ // Calendar validity: extract components based on format and verify via Date
420
+ const sep = /[-/]/.exec(str)?.[0] ?? '-'
421
+ const parts = str.split(sep)
422
+ let y: number, m: number, dd: number
423
+ if (fmt === 'DD-MM-YYYY' || fmt === 'DD/MM/YYYY') {
424
+ [dd, m, y] = [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)]
425
+ } else if (fmt === 'ISO8601') {
426
+ const d2 = new Date(str)
427
+ if (isNaN(d2.getTime())) {
428
+ dateFormat.errors = [{ keyword: 'dateFormat', message: Locale.getMessageText('date.format'), params: { format: schema } }]
429
+ return false
430
+ }
431
+ return true
432
+ } else {
433
+ [y, m, dd] = [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)]
434
+ }
435
+ // Verify the date exists (e.g., reject 2024-13-99, 2024-02-31)
436
+ const probe = new Date(y, m - 1, dd)
437
+ if (probe.getFullYear() !== y || probe.getMonth() !== m - 1 || probe.getDate() !== dd) {
438
+ dateFormat.errors = [{ keyword: 'dateFormat', message: Locale.getMessageText('date.format'), params: { format: schema } }]
439
+ return false
440
+ }
441
+ return true
442
+ }
443
+ ajv.addKeyword({ keyword: 'dateFormat', type: 'string', schemaType: 'string', validate: dateFormat, errors: true })
444
+
445
+ // dateGreater
446
+ const dateGreater: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
447
+ const dataDate = new Date(String(data))
448
+ const compareDate = new Date(String(schema))
449
+ if (isNaN(dataDate.getTime()) || isNaN(compareDate.getTime()) || dataDate <= compareDate) {
450
+ dateGreater.errors = [{
451
+ keyword: 'dateGreater',
452
+ message: Locale.getMessageText('date.greater'),
453
+ params: { limit: schema },
454
+ }]
455
+ return false
456
+ }
457
+ return true
458
+ }
459
+ ajv.addKeyword({ keyword: 'dateGreater', type: 'string', schemaType: 'string', validate: dateGreater, errors: true })
460
+
461
+ // dateLess
462
+ const dateLess: ValidateFnWithErrors = (schema: unknown, data: unknown): boolean => {
463
+ const dataDate = new Date(String(data))
464
+ const compareDate = new Date(String(schema))
465
+ if (isNaN(dataDate.getTime()) || isNaN(compareDate.getTime()) || dataDate >= compareDate) {
466
+ dateLess.errors = [{
467
+ keyword: 'dateLess',
468
+ message: Locale.getMessageText('date.less'),
469
+ params: { limit: schema },
470
+ }]
471
+ return false
472
+ }
473
+ return true
474
+ }
475
+ ajv.addKeyword({ keyword: 'dateLess', type: 'string', schemaType: 'string', validate: dateLess, errors: true })
476
+ }
477
+ }