@pyreon/validation 0.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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/lib/analysis/arktype.js.html +5406 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/analysis/valibot.js.html +5406 -0
- package/lib/analysis/zod.js.html +5406 -0
- package/lib/arktype.js +88 -0
- package/lib/arktype.js.map +1 -0
- package/lib/index.js +223 -0
- package/lib/index.js.map +1 -0
- package/lib/types/arktype.d.ts +90 -0
- package/lib/types/arktype.d.ts.map +1 -0
- package/lib/types/arktype2.d.ts +51 -0
- package/lib/types/arktype2.d.ts.map +1 -0
- package/lib/types/index.d.ts +195 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/valibot.d.ts +89 -0
- package/lib/types/valibot.d.ts.map +1 -0
- package/lib/types/valibot2.d.ts +51 -0
- package/lib/types/valibot2.d.ts.map +1 -0
- package/lib/types/zod.d.ts +86 -0
- package/lib/types/zod.d.ts.map +1 -0
- package/lib/types/zod2.d.ts +71 -0
- package/lib/types/zod2.d.ts.map +1 -0
- package/lib/valibot.js +87 -0
- package/lib/valibot.js.map +1 -0
- package/lib/zod.js +84 -0
- package/lib/zod.js.map +1 -0
- package/package.json +81 -0
- package/src/arktype.ts +103 -0
- package/src/index.ts +13 -0
- package/src/tests/setup.ts +1 -0
- package/src/tests/validation.test.ts +451 -0
- package/src/types.ts +33 -0
- package/src/utils.ts +21 -0
- package/src/valibot.ts +119 -0
- package/src/zod.ts +102 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
import { type } from 'arktype'
|
|
4
|
+
import { h } from '@pyreon/core'
|
|
5
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
6
|
+
import { useForm } from '@pyreon/form'
|
|
7
|
+
import { zodSchema, zodField } from '../zod'
|
|
8
|
+
import { valibotSchema, valibotField } from '../valibot'
|
|
9
|
+
import { arktypeSchema, arktypeField } from '../arktype'
|
|
10
|
+
import { issuesToRecord } from '../utils'
|
|
11
|
+
|
|
12
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function mountWith<T>(fn: () => T): { result: T; unmount: () => void } {
|
|
15
|
+
let result: T | undefined
|
|
16
|
+
const el = document.createElement('div')
|
|
17
|
+
document.body.appendChild(el)
|
|
18
|
+
const unmount = mount(
|
|
19
|
+
h(() => {
|
|
20
|
+
result = fn()
|
|
21
|
+
return null
|
|
22
|
+
}, null),
|
|
23
|
+
el,
|
|
24
|
+
)
|
|
25
|
+
return {
|
|
26
|
+
result: result!,
|
|
27
|
+
unmount: () => {
|
|
28
|
+
unmount()
|
|
29
|
+
el.remove()
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── issuesToRecord ──────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
describe('issuesToRecord', () => {
|
|
37
|
+
it('converts issues to a flat record', () => {
|
|
38
|
+
const result = issuesToRecord([
|
|
39
|
+
{ path: 'email', message: 'Required' },
|
|
40
|
+
{ path: 'password', message: 'Too short' },
|
|
41
|
+
])
|
|
42
|
+
expect(result).toEqual({ email: 'Required', password: 'Too short' })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('first error per field wins', () => {
|
|
46
|
+
const result = issuesToRecord([
|
|
47
|
+
{ path: 'email', message: 'Required' },
|
|
48
|
+
{ path: 'email', message: 'Invalid format' },
|
|
49
|
+
])
|
|
50
|
+
expect(result).toEqual({ email: 'Required' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns empty object for no issues', () => {
|
|
54
|
+
expect(issuesToRecord([])).toEqual({})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// ─── Zod Adapter ─────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe('zodSchema', () => {
|
|
61
|
+
const schema = z.object({
|
|
62
|
+
email: z.string().email('Invalid email'),
|
|
63
|
+
password: z.string().min(8, 'Min 8 chars'),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns empty record for valid data', async () => {
|
|
67
|
+
const validate = zodSchema(schema)
|
|
68
|
+
const result = await validate({ email: 'a@b.com', password: '12345678' })
|
|
69
|
+
expect(result).toEqual({})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns field errors for invalid data', async () => {
|
|
73
|
+
const validate = zodSchema(schema)
|
|
74
|
+
const result = await validate({ email: 'bad', password: 'short' })
|
|
75
|
+
expect(result.email).toBe('Invalid email')
|
|
76
|
+
expect(result.password).toBe('Min 8 chars')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns error for single invalid field', async () => {
|
|
80
|
+
const validate = zodSchema(schema)
|
|
81
|
+
const result = await validate({ email: 'a@b.com', password: 'short' })
|
|
82
|
+
expect(result.email).toBeUndefined()
|
|
83
|
+
expect(result.password).toBe('Min 8 chars')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('zodField', () => {
|
|
88
|
+
it('returns undefined for valid value', async () => {
|
|
89
|
+
const validate = zodField(z.string().email('Invalid email'))
|
|
90
|
+
expect(await validate('a@b.com', {})).toBeUndefined()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('returns error message for invalid value', async () => {
|
|
94
|
+
const validate = zodField(z.string().email('Invalid email'))
|
|
95
|
+
expect(await validate('bad', {})).toBe('Invalid email')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('works with number schemas', async () => {
|
|
99
|
+
const validate = zodField(z.number().min(0, 'Must be positive'))
|
|
100
|
+
expect(await validate(-1, {})).toBe('Must be positive')
|
|
101
|
+
expect(await validate(5, {})).toBeUndefined()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('zod + useForm integration', () => {
|
|
106
|
+
it('validates form with zod schema', async () => {
|
|
107
|
+
const schema = z.object({
|
|
108
|
+
email: z.string().email('Invalid email'),
|
|
109
|
+
password: z.string().min(8, 'Min 8 chars'),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const { result: form, unmount } = mountWith(() =>
|
|
113
|
+
useForm({
|
|
114
|
+
initialValues: { email: '', password: '' },
|
|
115
|
+
schema: zodSchema(schema),
|
|
116
|
+
onSubmit: () => {
|
|
117
|
+
/* noop */
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const valid = await form.validate()
|
|
123
|
+
expect(valid).toBe(false)
|
|
124
|
+
expect(form.fields.email.error()).toBe('Invalid email')
|
|
125
|
+
expect(form.fields.password.error()).toBe('Min 8 chars')
|
|
126
|
+
unmount()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('validates with field-level zod validators', async () => {
|
|
130
|
+
const { result: form, unmount } = mountWith(() =>
|
|
131
|
+
useForm({
|
|
132
|
+
initialValues: { email: '', age: 0 },
|
|
133
|
+
validators: {
|
|
134
|
+
email: zodField(z.string().email('Invalid')),
|
|
135
|
+
age: zodField(z.number().min(18, 'Must be 18+')),
|
|
136
|
+
},
|
|
137
|
+
onSubmit: () => {
|
|
138
|
+
/* noop */
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const valid = await form.validate()
|
|
144
|
+
expect(valid).toBe(false)
|
|
145
|
+
expect(form.fields.email.error()).toBe('Invalid')
|
|
146
|
+
expect(form.fields.age.error()).toBe('Must be 18+')
|
|
147
|
+
unmount()
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// ─── Valibot Adapter ─────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
describe('valibotSchema', () => {
|
|
154
|
+
const schema = v.object({
|
|
155
|
+
email: v.pipe(v.string(), v.email('Invalid email')),
|
|
156
|
+
password: v.pipe(v.string(), v.minLength(8, 'Min 8 chars')),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('returns empty record for valid data', async () => {
|
|
160
|
+
const validate = valibotSchema(schema, v.safeParseAsync)
|
|
161
|
+
const result = await validate({ email: 'a@b.com', password: '12345678' })
|
|
162
|
+
expect(result).toEqual({})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('returns field errors for invalid data', async () => {
|
|
166
|
+
const validate = valibotSchema(schema, v.safeParseAsync)
|
|
167
|
+
const result = await validate({ email: 'bad', password: 'short' })
|
|
168
|
+
expect(result.email).toBe('Invalid email')
|
|
169
|
+
expect(result.password).toBe('Min 8 chars')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('works with sync safeParse', async () => {
|
|
173
|
+
const validate = valibotSchema(schema, v.safeParse)
|
|
174
|
+
const result = await validate({ email: 'bad', password: 'short' })
|
|
175
|
+
expect(result.email).toBe('Invalid email')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('handles issues without path', async () => {
|
|
179
|
+
// Simulate a safeParse function that returns issues without path
|
|
180
|
+
const mockSafeParse = async () => ({
|
|
181
|
+
success: false as const,
|
|
182
|
+
issues: [{ message: 'Schema-level error' }],
|
|
183
|
+
})
|
|
184
|
+
const validate = valibotSchema({}, mockSafeParse)
|
|
185
|
+
const result = await validate({})
|
|
186
|
+
// Issue without path maps to empty string key
|
|
187
|
+
expect(result['' as keyof typeof result]).toBe('Schema-level error')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('handles result with undefined issues array', async () => {
|
|
191
|
+
const mockSafeParse = async () => ({
|
|
192
|
+
success: false as const,
|
|
193
|
+
// issues is undefined
|
|
194
|
+
})
|
|
195
|
+
const validate = valibotSchema({}, mockSafeParse)
|
|
196
|
+
const result = await validate({})
|
|
197
|
+
expect(result).toEqual({})
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('valibotField', () => {
|
|
202
|
+
it('returns undefined for valid value', async () => {
|
|
203
|
+
const validate = valibotField(
|
|
204
|
+
v.pipe(v.string(), v.email('Invalid email')),
|
|
205
|
+
v.safeParseAsync,
|
|
206
|
+
)
|
|
207
|
+
expect(await validate('a@b.com', {})).toBeUndefined()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('returns error message for invalid value', async () => {
|
|
211
|
+
const validate = valibotField(
|
|
212
|
+
v.pipe(v.string(), v.email('Invalid email')),
|
|
213
|
+
v.safeParseAsync,
|
|
214
|
+
)
|
|
215
|
+
expect(await validate('bad', {})).toBe('Invalid email')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('handles result with undefined issues', async () => {
|
|
219
|
+
const mockSafeParse = async () => ({
|
|
220
|
+
success: false as const,
|
|
221
|
+
})
|
|
222
|
+
const validate = valibotField({}, mockSafeParse)
|
|
223
|
+
expect(await validate('x', {})).toBeUndefined()
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('valibot + useForm integration', () => {
|
|
228
|
+
it('validates form with valibot schema', async () => {
|
|
229
|
+
const schema = v.object({
|
|
230
|
+
email: v.pipe(v.string(), v.email('Invalid email')),
|
|
231
|
+
password: v.pipe(v.string(), v.minLength(8, 'Min 8 chars')),
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const { result: form, unmount } = mountWith(() =>
|
|
235
|
+
useForm({
|
|
236
|
+
initialValues: { email: '', password: '' },
|
|
237
|
+
schema: valibotSchema(schema, v.safeParseAsync),
|
|
238
|
+
onSubmit: () => {
|
|
239
|
+
/* noop */
|
|
240
|
+
},
|
|
241
|
+
}),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
const valid = await form.validate()
|
|
245
|
+
expect(valid).toBe(false)
|
|
246
|
+
expect(form.fields.email.error()).toBe('Invalid email')
|
|
247
|
+
expect(form.fields.password.error()).toBe('Min 8 chars')
|
|
248
|
+
unmount()
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// ─── ArkType Adapter ─────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
describe('arktypeSchema', () => {
|
|
255
|
+
const schema = type({
|
|
256
|
+
email: 'string.email',
|
|
257
|
+
password: 'string >= 8',
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('returns empty record for valid data', async () => {
|
|
261
|
+
const validate = arktypeSchema(schema)
|
|
262
|
+
const result = await validate({ email: 'a@b.com', password: '12345678' })
|
|
263
|
+
expect(result).toEqual({})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('returns field errors for invalid data', async () => {
|
|
267
|
+
const validate = arktypeSchema(schema)
|
|
268
|
+
const result = await validate({ email: 'bad', password: 'short' })
|
|
269
|
+
expect(result.email).toBeDefined()
|
|
270
|
+
expect(result.password).toBeDefined()
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('arktypeField', () => {
|
|
275
|
+
it('returns undefined for valid value', async () => {
|
|
276
|
+
const validate = arktypeField(type('string.email'))
|
|
277
|
+
expect(await validate('a@b.com', {})).toBeUndefined()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('returns error message for invalid value', async () => {
|
|
281
|
+
const validate = arktypeField(type('string.email'))
|
|
282
|
+
const result = await validate('bad', {})
|
|
283
|
+
expect(result).toBeDefined()
|
|
284
|
+
expect(typeof result).toBe('string')
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('zodSchema catch branch', () => {
|
|
289
|
+
it('captures Error when safeParseAsync throws an Error', async () => {
|
|
290
|
+
const throwingSchema = {
|
|
291
|
+
safeParseAsync: () => {
|
|
292
|
+
throw new Error('Zod schema exploded')
|
|
293
|
+
},
|
|
294
|
+
safeParse: () => {
|
|
295
|
+
throw new Error('Zod schema exploded')
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
const validate = zodSchema(throwingSchema as any)
|
|
299
|
+
const result = await validate({ email: '', password: '' })
|
|
300
|
+
expect(result['' as keyof typeof result]).toBe('Zod schema exploded')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('captures non-Error when safeParseAsync throws a string', async () => {
|
|
304
|
+
const throwingSchema = {
|
|
305
|
+
safeParseAsync: () => {
|
|
306
|
+
throw 'raw string error'
|
|
307
|
+
},
|
|
308
|
+
safeParse: () => {
|
|
309
|
+
throw 'raw string error'
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
const validate = zodSchema(throwingSchema as any)
|
|
313
|
+
const result = await validate({ email: '', password: '' })
|
|
314
|
+
expect(result['' as keyof typeof result]).toBe('raw string error')
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
describe('zodField catch branch', () => {
|
|
319
|
+
it('captures Error when safeParseAsync throws an Error', async () => {
|
|
320
|
+
const throwingSchema = {
|
|
321
|
+
safeParseAsync: () => {
|
|
322
|
+
throw new Error('Zod field exploded')
|
|
323
|
+
},
|
|
324
|
+
safeParse: () => {
|
|
325
|
+
throw new Error('Zod field exploded')
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
const validate = zodField(throwingSchema as any)
|
|
329
|
+
const result = await validate('test', {})
|
|
330
|
+
expect(result).toBe('Zod field exploded')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('captures non-Error when safeParseAsync throws a string', async () => {
|
|
334
|
+
const throwingSchema = {
|
|
335
|
+
safeParseAsync: () => {
|
|
336
|
+
throw 'raw zod field error'
|
|
337
|
+
},
|
|
338
|
+
safeParse: () => {
|
|
339
|
+
throw 'raw zod field error'
|
|
340
|
+
},
|
|
341
|
+
}
|
|
342
|
+
const validate = zodField(throwingSchema as any)
|
|
343
|
+
const result = await validate('test', {})
|
|
344
|
+
expect(result).toBe('raw zod field error')
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('valibotSchema catch branch', () => {
|
|
349
|
+
it('captures Error when safeParse function throws an Error', async () => {
|
|
350
|
+
const throwingParse = () => {
|
|
351
|
+
throw new Error('Valibot schema exploded')
|
|
352
|
+
}
|
|
353
|
+
const validate = valibotSchema({}, throwingParse)
|
|
354
|
+
const result = await validate({ email: '', password: '' })
|
|
355
|
+
expect(result['' as keyof typeof result]).toBe('Valibot schema exploded')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('captures non-Error when safeParse function throws a string', async () => {
|
|
359
|
+
const throwingParse = () => {
|
|
360
|
+
throw 'raw valibot schema error'
|
|
361
|
+
}
|
|
362
|
+
const validate = valibotSchema({}, throwingParse)
|
|
363
|
+
const result = await validate({ email: '', password: '' })
|
|
364
|
+
expect(result['' as keyof typeof result]).toBe('raw valibot schema error')
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
describe('valibotField catch branch', () => {
|
|
369
|
+
it('captures Error when safeParse function throws an Error', async () => {
|
|
370
|
+
const throwingParse = () => {
|
|
371
|
+
throw new Error('Valibot field exploded')
|
|
372
|
+
}
|
|
373
|
+
const validate = valibotField({}, throwingParse)
|
|
374
|
+
const result = await validate('test', {})
|
|
375
|
+
expect(result).toBe('Valibot field exploded')
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('captures non-Error when safeParse function throws a string', async () => {
|
|
379
|
+
const throwingParse = () => {
|
|
380
|
+
throw 'raw valibot field error'
|
|
381
|
+
}
|
|
382
|
+
const validate = valibotField({}, throwingParse)
|
|
383
|
+
const result = await validate('test', {})
|
|
384
|
+
expect(result).toBe('raw valibot field error')
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
describe('arktypeSchema catch branch', () => {
|
|
389
|
+
it('captures Error when schema function throws an Error', async () => {
|
|
390
|
+
const throwingSchema = () => {
|
|
391
|
+
throw new Error('ArkType schema exploded')
|
|
392
|
+
}
|
|
393
|
+
const validate = arktypeSchema(throwingSchema)
|
|
394
|
+
const result = await validate({ email: '', password: '' })
|
|
395
|
+
expect(result['' as keyof typeof result]).toBe('ArkType schema exploded')
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('captures non-Error when schema function throws a string', async () => {
|
|
399
|
+
const throwingSchema = () => {
|
|
400
|
+
throw 'raw arktype schema error'
|
|
401
|
+
}
|
|
402
|
+
const validate = arktypeSchema(throwingSchema)
|
|
403
|
+
const result = await validate({ email: '', password: '' })
|
|
404
|
+
expect(result['' as keyof typeof result]).toBe('raw arktype schema error')
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
describe('arktypeField catch branch', () => {
|
|
409
|
+
it('captures Error when schema function throws an Error', async () => {
|
|
410
|
+
const throwingSchema = () => {
|
|
411
|
+
throw new Error('ArkType field exploded')
|
|
412
|
+
}
|
|
413
|
+
const validate = arktypeField(throwingSchema)
|
|
414
|
+
const result = await validate('test', {})
|
|
415
|
+
expect(result).toBe('ArkType field exploded')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('captures non-Error when schema function throws a string', async () => {
|
|
419
|
+
const throwingSchema = () => {
|
|
420
|
+
throw 'raw arktype field error'
|
|
421
|
+
}
|
|
422
|
+
const validate = arktypeField(throwingSchema)
|
|
423
|
+
const result = await validate('test', {})
|
|
424
|
+
expect(result).toBe('raw arktype field error')
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
describe('arktype + useForm integration', () => {
|
|
429
|
+
it('validates form with arktype schema', async () => {
|
|
430
|
+
const schema = type({
|
|
431
|
+
email: 'string.email',
|
|
432
|
+
password: 'string >= 8',
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const { result: form, unmount } = mountWith(() =>
|
|
436
|
+
useForm({
|
|
437
|
+
initialValues: { email: '', password: '' },
|
|
438
|
+
schema: arktypeSchema(schema),
|
|
439
|
+
onSubmit: () => {
|
|
440
|
+
/* noop */
|
|
441
|
+
},
|
|
442
|
+
}),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
const valid = await form.validate()
|
|
446
|
+
expect(valid).toBe(false)
|
|
447
|
+
expect(form.fields.email!.error()).toBeDefined()
|
|
448
|
+
expect(form.fields.password!.error()).toBeDefined()
|
|
449
|
+
unmount()
|
|
450
|
+
})
|
|
451
|
+
})
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SchemaValidateFn,
|
|
3
|
+
ValidateFn,
|
|
4
|
+
ValidationError,
|
|
5
|
+
} from '@pyreon/form'
|
|
6
|
+
|
|
7
|
+
/** Re-export form types for convenience. */
|
|
8
|
+
export type { SchemaValidateFn, ValidateFn, ValidationError }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generic issue produced by any schema library.
|
|
12
|
+
* Adapters normalize library-specific errors into this shape.
|
|
13
|
+
*/
|
|
14
|
+
export interface ValidationIssue {
|
|
15
|
+
/** Dot-separated field path (e.g. "address.city"). */
|
|
16
|
+
path: string
|
|
17
|
+
/** Human-readable error message. */
|
|
18
|
+
message: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A generic schema adapter transforms library-specific parse results
|
|
23
|
+
* into a flat record of field → error message.
|
|
24
|
+
*/
|
|
25
|
+
export type SchemaAdapter<TSchema> = <TValues extends Record<string, unknown>>(
|
|
26
|
+
schema: TSchema,
|
|
27
|
+
) => SchemaValidateFn<TValues>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A generic field adapter transforms a library-specific schema
|
|
31
|
+
* into a single-field validator function.
|
|
32
|
+
*/
|
|
33
|
+
export type FieldAdapter<TSchema> = <T>(schema: TSchema) => ValidateFn<T>
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ValidationError } from '@pyreon/form'
|
|
2
|
+
import type { ValidationIssue } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert an array of validation issues into a flat field → error record.
|
|
6
|
+
* For nested paths like ["address", "city"], produces "address.city".
|
|
7
|
+
* When multiple issues exist for the same path, the first message wins.
|
|
8
|
+
*/
|
|
9
|
+
export function issuesToRecord<TValues extends Record<string, unknown>>(
|
|
10
|
+
issues: ValidationIssue[],
|
|
11
|
+
): Partial<Record<keyof TValues, ValidationError>> {
|
|
12
|
+
const errors = {} as Partial<Record<keyof TValues, ValidationError>>
|
|
13
|
+
for (const issue of issues) {
|
|
14
|
+
const key = issue.path as keyof TValues
|
|
15
|
+
// First error per field wins
|
|
16
|
+
if (errors[key] === undefined) {
|
|
17
|
+
errors[key] = issue.message
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return errors
|
|
21
|
+
}
|
package/src/valibot.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SchemaValidateFn,
|
|
3
|
+
ValidateFn,
|
|
4
|
+
ValidationError,
|
|
5
|
+
} from '@pyreon/form'
|
|
6
|
+
import type { ValidationIssue } from './types'
|
|
7
|
+
import { issuesToRecord } from './utils'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Minimal Valibot-compatible interfaces so we don't require valibot as a hard dep.
|
|
11
|
+
*/
|
|
12
|
+
interface ValibotPathItem {
|
|
13
|
+
key: string | number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ValibotIssue {
|
|
17
|
+
path?: ValibotPathItem[]
|
|
18
|
+
message: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ValibotSafeParseResult {
|
|
22
|
+
success: boolean
|
|
23
|
+
output?: unknown
|
|
24
|
+
issues?: ValibotIssue[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Any function that takes (schema, input, ...rest) and returns a parse result.
|
|
29
|
+
* Valibot's safeParse/safeParseAsync have generic constraints on the schema
|
|
30
|
+
* parameter that can't be expressed without importing Valibot types. We accept
|
|
31
|
+
* any callable and cast internally.
|
|
32
|
+
*/
|
|
33
|
+
// biome-ignore lint/complexity/noBannedTypes: must accept any valibot parse function
|
|
34
|
+
type GenericSafeParseFn = Function
|
|
35
|
+
|
|
36
|
+
function valibotIssuesToGeneric(issues: ValibotIssue[]): ValidationIssue[] {
|
|
37
|
+
return issues.map((issue) => ({
|
|
38
|
+
path: issue.path?.map((p) => String(p.key)).join('.') ?? '',
|
|
39
|
+
message: issue.message,
|
|
40
|
+
}))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type InternalParseFn = (
|
|
44
|
+
schema: unknown,
|
|
45
|
+
input: unknown,
|
|
46
|
+
) => ValibotSafeParseResult | Promise<ValibotSafeParseResult>
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a form-level schema validator from a Valibot schema.
|
|
50
|
+
*
|
|
51
|
+
* Valibot uses standalone functions rather than methods, so you must pass
|
|
52
|
+
* the `safeParseAsync` (or `safeParse`) function from valibot.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* import * as v from 'valibot'
|
|
56
|
+
* import { valibotSchema } from '@pyreon/validation/valibot'
|
|
57
|
+
*
|
|
58
|
+
* const schema = v.object({
|
|
59
|
+
* email: v.pipe(v.string(), v.email()),
|
|
60
|
+
* password: v.pipe(v.string(), v.minLength(8)),
|
|
61
|
+
* })
|
|
62
|
+
*
|
|
63
|
+
* const form = useForm({
|
|
64
|
+
* initialValues: { email: '', password: '' },
|
|
65
|
+
* schema: valibotSchema(schema, v.safeParseAsync),
|
|
66
|
+
* onSubmit: (values) => { ... },
|
|
67
|
+
* })
|
|
68
|
+
*/
|
|
69
|
+
export function valibotSchema<TValues extends Record<string, unknown>>(
|
|
70
|
+
schema: unknown,
|
|
71
|
+
safeParseFn: GenericSafeParseFn,
|
|
72
|
+
): SchemaValidateFn<TValues> {
|
|
73
|
+
const parse = safeParseFn as InternalParseFn
|
|
74
|
+
return async (values: TValues) => {
|
|
75
|
+
try {
|
|
76
|
+
const result = await parse(schema, values)
|
|
77
|
+
if (result.success)
|
|
78
|
+
return {} as Partial<Record<keyof TValues, ValidationError>>
|
|
79
|
+
return issuesToRecord<TValues>(
|
|
80
|
+
valibotIssuesToGeneric(result.issues ?? []),
|
|
81
|
+
)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return {
|
|
84
|
+
'': err instanceof Error ? err.message : String(err),
|
|
85
|
+
} as Partial<Record<keyof TValues, ValidationError>>
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a single-field validator from a Valibot schema.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* import * as v from 'valibot'
|
|
95
|
+
* import { valibotField } from '@pyreon/validation/valibot'
|
|
96
|
+
*
|
|
97
|
+
* const form = useForm({
|
|
98
|
+
* initialValues: { email: '' },
|
|
99
|
+
* validators: {
|
|
100
|
+
* email: valibotField(v.pipe(v.string(), v.email('Invalid email')), v.safeParseAsync),
|
|
101
|
+
* },
|
|
102
|
+
* onSubmit: (values) => { ... },
|
|
103
|
+
* })
|
|
104
|
+
*/
|
|
105
|
+
export function valibotField<T>(
|
|
106
|
+
schema: unknown,
|
|
107
|
+
safeParseFn: GenericSafeParseFn,
|
|
108
|
+
): ValidateFn<T> {
|
|
109
|
+
const parse = safeParseFn as InternalParseFn
|
|
110
|
+
return async (value: T) => {
|
|
111
|
+
try {
|
|
112
|
+
const result = await parse(schema, value)
|
|
113
|
+
if (result.success) return undefined
|
|
114
|
+
return result.issues?.[0]?.message
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return err instanceof Error ? err.message : String(err)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|