@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.
@@ -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
+ }