Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ pval.oneOf(['create', 'edit'] as const)
pval.arrayOf(pval.isString)
pval.shape({ title: pval.isString, count: pval.isNumber })
pval.refOf(pval.isString)
pval.fail('title', 'expected non-empty string')
pval.describe('value')
pval.fail('title', 'expected non-empty string, got string ("")')
```

### Dynamic bindings and refs
Expand Down Expand Up @@ -204,7 +205,7 @@ import { pval, type PropValidator } from 'regor'

const isNonEmptyString: PropValidator<string> = (value, name) => {
if (typeof value !== 'string' || value.trim() === '') {
pval.fail(name, 'expected non-empty string')
pval.fail(name, `expected non-empty string, ${pval.describe(value)}`)
}
}

Expand All @@ -219,7 +220,7 @@ Custom validators can also use the third `head` argument:
const startsWithPrefix: PropValidator<string> = (value, name, head) => {
const ctx = head.requireContext(AppServices)
if (typeof value !== 'string' || !value.startsWith(ctx.prefix)) {
pval.fail(name, 'expected prefixed value')
pval.fail(name, `expected prefixed value, ${pval.describe(value)}`)
}
}
```
Expand Down
25 changes: 20 additions & 5 deletions docs-site/src/content/docs/api/pval.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ head.validateProps({
Nested errors include the failing index:

```txt
Invalid prop "tags[1]": expected string.
Invalid prop "tags[1]": expected string, got number (2).
```

### `pval.shape({ ... })`
Expand All @@ -155,7 +155,7 @@ head.validateProps({
Nested errors include the failing path:

```txt
Invalid prop "meta.slug": expected string.
Invalid prop "meta.slug": expected string, got number (2).
```

### `pval.refOf(validator)`
Expand All @@ -170,6 +170,21 @@ head.validateProps({
})
```

### `pval.describe(value)`

Returns the same `got ...` description used by Regor's built-in validators.

This is useful in custom validators when you want your own error details to
match the built-in validator style, including explicit Regor ref handling.

```ts
const isPositiveNumber: PropValidator<number> = (value, name) => {
if (typeof value !== 'number' || value <= 0) {
pval.fail(name, `expected positive number, ${pval.describe(value)}`)
}
}
```

### `pval.fail(name, detail)`

Throws the same structured validation failure used by Regor's built-in validators.
Expand All @@ -181,7 +196,7 @@ format the final component-aware error consistently.
```ts
const isNonEmptyString: PropValidator<string> = (value, name) => {
if (typeof value !== 'string' || value.trim() === '') {
pval.fail(name, 'expected non-empty string')
pval.fail(name, `expected non-empty string, ${pval.describe(value)}`)
}
}
```
Expand All @@ -201,7 +216,7 @@ import { pval, type PropValidator } from 'regor'

const isNonEmptyString: PropValidator<string> = (value, name) => {
if (typeof value !== 'string' || value.trim() === '') {
pval.fail(name, 'expected non-empty string')
pval.fail(name, `expected non-empty string, ${pval.describe(value)}`)
}
}
```
Expand All @@ -212,7 +227,7 @@ Custom validators also receive `head` as the third argument:
const startsWithPrefix: PropValidator<string> = (value, name, head) => {
const services = head.requireContext(AppServices)
if (typeof value !== 'string' || !value.startsWith(services.prefix)) {
pval.fail(name, 'expected prefixed value')
pval.fail(name, `expected prefixed value, ${pval.describe(value)}`)
}
}
```
Expand Down
7 changes: 4 additions & 3 deletions docs-site/src/content/docs/guide/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ const EditorCard = defineComponent<EditorCard>(
8. `pval.arrayOf(validator)`
9. `pval.shape({ ... })`
10. `pval.refOf(validator)`
11. `pval.fail(name, detail)`
11. `pval.describe(value)`
12. `pval.fail(name, detail)`

Example:

Expand Down Expand Up @@ -230,7 +231,7 @@ import { pval, type PropValidator } from 'regor'

const isNonEmptyString: PropValidator<string> = (value, name) => {
if (typeof value !== 'string' || value.trim() === '') {
pval.fail(name, 'expected non-empty string')
pval.fail(name, `expected non-empty string, ${pval.describe(value)}`)
}
}

Expand All @@ -245,7 +246,7 @@ Custom validators can also use the third `head` argument:
const startsWithParentPrefix: PropValidator<string> = (value, name, head) => {
const ctx = head.requireContext(AppServices)
if (typeof value !== 'string' || !value.startsWith(ctx.prefix)) {
pval.fail(name, 'expected prefixed value')
pval.fail(name, `expected prefixed value, ${pval.describe(value)}`)
}
}
```
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/guide/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ import { pval, type PropValidator } from 'regor'

const isNonEmptyString: PropValidator<string> = (value, name) => {
if (typeof value !== 'string' || value.trim() === '') {
pval.fail(name, 'expected non-empty string')
pval.fail(name, `expected non-empty string, ${pval.describe(value)}`)
}
}
```
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "regor",
"version": "1.4.8",
"version": "1.4.9",
"description": "A modern UI framework for web and desktop applications, inspired by Vue's concepts and powered by simplicity and flexibility.",
"author": "Ahmed Yasin Koculu",
"license": "MIT",
Expand Down
116 changes: 103 additions & 13 deletions src/app/propValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export type InferPropValidationSchema<TSchema extends Record<string, unknown>> =
* ```ts
* const isNonEmptyString: PropValidator<string> = (value, name) => {
* if (typeof value !== 'string' || value.trim() === '') {
* pval.fail(name, 'expected non-empty string')
* pval.fail(name, `expected non-empty string, ${pval.describe(value)}`)
* }
* }
* ```
Expand All @@ -93,6 +93,9 @@ export const fail = (name: string, message: string): never => {
const describeValue = (value: unknown): string => {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (isRef(value)) {
return `ref<${describeValue(value())}>`
}
if (typeof value === 'string') return 'string'
if (typeof value === 'number') return 'number'
if (typeof value === 'boolean') return 'boolean'
Expand All @@ -109,6 +112,84 @@ const describeValue = (value: unknown): string => {
return ctorName && ctorName !== 'Object' ? ctorName : 'object'
}

const trimPreview = (value: string): string => {
return value.length > 60 ? `${value.slice(0, 57)}...` : value
}

const formatPreview = (value: unknown, depth = 0): string => {
const maxPreviewDepth = 1
const maxArrayItems = 5
const maxObjectEntries = 5

if (depth > maxPreviewDepth) return 'unknown'
if (isRef(value)) {
const refValue = value()
return `ref(${formatPreview(refValue, depth + 1)})`
}
if (typeof value === 'string') return trimPreview(JSON.stringify(value))
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'bigint'
) {
return String(value)
}
if (typeof value === 'symbol') return String(value)
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (value instanceof Date) return value.toISOString()
if (value instanceof RegExp) return String(value)
if (isArray(value)) {
const items = value
.slice(0, maxArrayItems)
.map((x) => formatPreview(x, depth + 1))
.join(', ')
return value.length > maxArrayItems ? `[${items}, ...]` : `[${items}]`
}
if (isObject(value)) {
const entries = Object.entries(value).slice(0, maxObjectEntries)
if (entries.length === 0) return '{}'
const text = entries
.map(([key, entry]) => {
const preview = formatPreview(entry, depth + 1)
return `${key}: ${preview}`
})
.join(', ')
return Object.keys(value).length > maxObjectEntries
? `{ ${text}, ... }`
: `{ ${text} }`
}
return 'unknown'
}

/**
* Describes the received runtime value in the same style used by Regor's
* built-in prop validators.
*
* This is useful in custom validators when you want failure messages to stay
* consistent with `pval.isString`, `pval.isNumber`, `pval.refOf(...)`, and the
* other built-ins. The description includes both a received type label and a
* compact preview of the value. Regor refs are shown explicitly together with
* their current value.
*
* Example:
* ```ts
* const isPositiveNumber: PropValidator<number> = (value, name) => {
* if (typeof value !== 'number' || value <= 0) {
* pval.fail(name, `expected positive number, ${pval.describe(value)}`)
* }
* }
* ```
*
* @param value - Raw runtime value that failed validation.
* @returns A compact `got ...` description suitable for validator errors.
*/
export const describe = (value: unknown): string => {
const type = describeValue(value)
const preview = formatPreview(value)
return `got ${type} (${preview})`
}

const formatLiteral = (value: unknown): string => {
if (typeof value === 'string') return `"${value}"`
if (typeof value === 'number' || typeof value === 'boolean') {
Expand All @@ -123,21 +204,24 @@ const formatLiteral = (value: unknown): string => {
* Validates that a prop is a string.
*/
export const isString: PropValidator<string> = (value, name) => {
if (typeof value !== 'string') fail(name, 'expected string')
if (typeof value !== 'string')
fail(name, `expected string, ${describe(value)}`)
}

/**
* Validates that a prop is a number.
*/
export const isNumber: PropValidator<number> = (value, name) => {
if (typeof value !== 'number') fail(name, 'expected number')
if (typeof value !== 'number')
fail(name, `expected number, ${describe(value)}`)
}

/**
* Validates that a prop is a boolean.
*/
export const isBoolean: PropValidator<boolean> = (value, name) => {
if (typeof value !== 'boolean') fail(name, 'expected boolean')
if (typeof value !== 'boolean')
fail(name, `expected boolean, ${describe(value)}`)
}

/**
Expand All @@ -151,7 +235,10 @@ export const isClass = <TValue extends object>(
): PropValidator<TValue> => {
return (value, name) => {
if (!(value instanceof ctor)) {
fail(name, `expected instance of ${ctor.name || 'provided class'}`)
fail(
name,
`expected instance of ${ctor.name || 'provided class'}, ${describe(value)}`,
)
}
}
}
Expand Down Expand Up @@ -190,7 +277,7 @@ export const oneOf = <const TValue extends readonly unknown[]>(
if (values.includes(value)) return
fail(
name,
`expected one of ${values.map((x) => formatLiteral(x)).join(', ')}`,
`expected one of ${values.map((x) => formatLiteral(x)).join(', ')}, ${describe(value)}`,
)
}
}
Expand All @@ -203,7 +290,7 @@ export const arrayOf = <TValue>(
validator: PropValidator<TValue>,
): PropValidator<TValue[]> => {
return (value, name, head) => {
if (!isArray(value)) fail(name, 'expected array')
if (!isArray(value)) fail(name, `expected array, ${describe(value)}`)
const items = value as unknown[]
for (let i = 0; i < items.length; ++i) {
validator(items[i], `${name}[${i}]`, head)
Expand All @@ -219,7 +306,7 @@ export const shape = <TSchema extends ValidationSchemaLike>(
schema: TSchema,
): PropValidator<InferPropValidationSchema<TSchema>> => {
return (value, name, head) => {
if (!isObject(value)) fail(name, 'expected object')
if (!isObject(value)) fail(name, `expected object, ${describe(value)}`)
const record = value as Record<string, unknown>
for (const key in schema) {
const validator: PropValidator<unknown> = schema[key]
Expand All @@ -238,18 +325,20 @@ export const refOf = <TValue>(
validator: PropValidator<TValue>,
): PropValidator<AnyRef> => {
return (value, name, head) => {
if (!isRef(value)) fail(name, 'expected ref')
const refValue = value as AnyRef
validator(refValue(), `${name}.value`, head)
if (isRef(value)) {
validator(value(), `${name}.value`, head)
return
}
fail(name, `expected ref, ${describe(value)}`)
}
}

/**
* Built-in prop-validator namespace used with `head.validateProps(...)`.
*
* This namespace includes both ready-made validators and composition helpers.
* Custom validators can also use `pval.fail(...)` to produce the same
* structured failure shape as Regor's built-in validators.
* Custom validators can also use `pval.fail(...)` and `pval.describe(...)`
* to produce messages in the same style as Regor's built-in validators.
*
* Example:
* ```ts
Expand All @@ -264,6 +353,7 @@ export const refOf = <TValue>(
*/
export const pval = {
fail,
describe,
isString,
isNumber,
isBoolean,
Expand Down
6 changes: 4 additions & 2 deletions tests/app/component-head.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ test('component head validateProps throws with nested prop path details', () =>
tags: pval.arrayOf(pval.isString),
}),
}),
).toThrow('Invalid prop "meta.tags[1]" on <div>: expected string.')
).toThrow(
'Invalid prop "meta.tags[1]" on <div>: expected string, got number (2).',
)
})

test('component head validator utilities support class and ref validation', () => {
Expand Down Expand Up @@ -312,7 +314,7 @@ test('component head validateProps warns instead of throwing in warn mode', () =
).not.toThrow()
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(String(warnSpy.mock.calls[0][0])).toContain(
'Invalid prop "count" on <div>: expected number.',
'Invalid prop "count" on <div>: expected number, got string ("bad").',
)
} finally {
warnSpy.mockRestore()
Expand Down
6 changes: 4 additions & 2 deletions tests/app/prop-validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ test('component props validation throws before invalid component mount completes
template: html`<ValidatedCard :count="badCount"></ValidatedCard>`,
},
),
).toThrow('Invalid prop "count" on <validatedcard>: expected number.')
).toThrow(
'Invalid prop "count" on <validatedcard>: expected number, got ref<string> (ref("oops")).',
)
expect(root.querySelector('.summary')).toBeNull()
})

Expand Down Expand Up @@ -187,7 +189,7 @@ test('component props validation warns and continues mount when config mode is w
).not.toThrow()
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(String(warnSpy.mock.calls[0][0])).toContain(
'Invalid prop "count" on <validatedcard>: expected number.',
'Invalid prop "count" on <validatedcard>: expected number, got string ("oops").',
)
expect(root.querySelector('.summary')?.textContent).toBe('oops')
} finally {
Expand Down
Loading