From fd20b43ef91f78eb20012da5c099edcc81ec7260 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Mon, 6 Apr 2026 03:00:03 +0200 Subject: [PATCH] Improve prop-validator message format --- README.md | 8 ++ docs-site/src/content/docs/api/pval.md | 14 ++++ package.json | 2 +- src/app/propValidators.ts | 43 ++++++++-- tests/app/component-head.spec.ts | 106 +++++++++++++++++++++++++ tests/app/prop-validation.spec.ts | 2 +- 6 files changed, 167 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f8adc56..98563e1 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,14 @@ pval.describe('value') pval.fail('title', 'expected non-empty string, got string ("")') ``` +`pval.or(...)` is useful for union-style runtime contracts: + +```ts +head.validateProps({ + value: pval.or(pval.isString, pval.refOf(pval.isString)), +}) +``` + ### Dynamic bindings and refs Single-prop dynamic bindings like `:title="titleRef"` flow into component props as refs. diff --git a/docs-site/src/content/docs/api/pval.md b/docs-site/src/content/docs/api/pval.md index 275087c..1844c77 100644 --- a/docs-site/src/content/docs/api/pval.md +++ b/docs-site/src/content/docs/api/pval.md @@ -125,6 +125,20 @@ head.validateProps({ }) ``` +It also works well with ref-aware unions: + +```ts +head.validateProps({ + value: pval.or(pval.isString, pval.refOf(pval.isString)), +}) +``` + +Example failure: + +```txt +Invalid prop "value": expected string or expected ref, got ref(1). +``` + ### `pval.oneOf(values)` Ensures the value is one of the provided literals. diff --git a/package.json b/package.json index fdccd95..ff051d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "regor", - "version": "1.5.0", + "version": "1.5.1", "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", diff --git a/src/app/propValidators.ts b/src/app/propValidators.ts index af87793..6837e6e 100644 --- a/src/app/propValidators.ts +++ b/src/app/propValidators.ts @@ -192,6 +192,9 @@ const formatPreview = (value: unknown, depth = 0): string => { * @returns A compact `got ...` description suitable for validator errors. */ export const describe = (value: unknown): string => { + if (isRef(value)) { + return `got ${describeValue(value)}(${formatPreview(value())})` + } const type = describeValue(value) const preview = formatPreview(value) return `got ${type} (${preview})` @@ -203,15 +206,36 @@ const getErrorDetail = (error: unknown): string => { return String(error) } -const formatOrDetail = (errors: unknown[], value: unknown): string => { +const trimReceivedSuffix = (detail: string, value: unknown): string => { const received = `, ${describe(value)}.` + return detail.endsWith(received) ? detail.slice(0, -received.length) : detail +} + +const getOrReason = (name: string, error: unknown, value: unknown): string => { + if (error instanceof PropValidationError) { + if (error.propPath === name) { + return trimReceivedSuffix(error.detail, value) + } + if (error.propPath === `${name}.value` && isRef(value)) { + const innerReason = trimReceivedSuffix(error.detail, value()) + if (innerReason.startsWith('expected ')) { + return `expected ref<${innerReason.slice('expected '.length)}>` + } + return `expected ref where ${innerReason}` + } + } + return getErrorDetail(error) +} + +const formatOrDetail = ( + name: string, + errors: unknown[], + value: unknown, +): string => { const reasons: string[] = [] for (const error of errors) { - const detail = getErrorDetail(error) - const reason = detail.endsWith(received) - ? detail.slice(0, -received.length) - : detail + const reason = getOrReason(name, error, value) if (!reasons.includes(reason)) reasons.push(reason) } @@ -309,6 +333,13 @@ export const nullable = ( * value: pval.or(pval.isString, pval.isNumber), * }) * ``` + * + * Example with refs: + * ```ts + * head.validateProps({ + * value: pval.or(pval.isString, pval.refOf(pval.isString)), + * }) + * ``` */ export const or = ( ...validators: TValidators @@ -323,7 +354,7 @@ export const or = ( errors.push(error) } } - fail(name, formatOrDetail(errors, value)) + fail(name, formatOrDetail(name, errors, value)) } } diff --git a/tests/app/component-head.spec.ts b/tests/app/component-head.spec.ts index 0e2059e..45f1704 100644 --- a/tests/app/component-head.spec.ts +++ b/tests/app/component-head.spec.ts @@ -238,6 +238,112 @@ test('component head validator utilities support or validation', () => { ) }) +test('component head validator utilities support or validation with ref branches', () => { + const host = document.createElement('div') + const start = document.createComment('s') + const end = document.createComment('e') + const head = new ComponentHead( + { + value: ref(1), + }, + host, + [], + start, + end, + 'throw', + ) + + expect(() => + head.validateProps({ + value: pval.or(pval.isString, pval.refOf(pval.isString)), + }), + ).toThrow( + 'Invalid prop "value" on
: expected string or expected ref, got ref(1).', + ) +}) + +test('component head validator utilities support or validation with alternate shapes', () => { + const host = document.createElement('div') + const start = document.createComment('s') + const end = document.createComment('e') + + const okHead = new ComponentHead( + { + value: { + slug: 'post-1', + }, + }, + host, + [], + start, + end, + 'throw', + ) + + expect(() => + okHead.validateProps({ + value: pval.or( + pval.shape({ + slug: pval.isString, + }), + pval.shape({ + id: pval.isNumber, + }), + ), + }), + ).not.toThrow() +}) + +test('component head validator utilities support nested or validation inside shapes', () => { + const host = document.createElement('div') + const start = document.createComment('s') + const end = document.createComment('e') + + const okHead = new ComponentHead( + { + meta: { + owner: ref('ada'), + }, + }, + host, + [], + start, + end, + 'throw', + ) + + expect(() => + okHead.validateProps({ + meta: pval.shape({ + owner: pval.or(pval.isString, pval.refOf(pval.isString)), + }), + }), + ).not.toThrow() + + const badHead = new ComponentHead( + { + meta: { + owner: ref(12), + }, + }, + host, + [], + start, + end, + 'throw', + ) + + expect(() => + badHead.validateProps({ + meta: pval.shape({ + owner: pval.or(pval.isString, pval.refOf(pval.isString)), + }), + }), + ).toThrow( + 'Invalid prop "meta.owner" on
: expected string or expected ref, got ref(12).', + ) +}) + test('component head validateProps accepts custom user validators', () => { const host = document.createElement('div') const start = document.createComment('s') diff --git a/tests/app/prop-validation.spec.ts b/tests/app/prop-validation.spec.ts index a0fa854..cb17e49 100644 --- a/tests/app/prop-validation.spec.ts +++ b/tests/app/prop-validation.spec.ts @@ -176,7 +176,7 @@ test('component props validation throws before invalid component mount completes }, ), ).toThrow( - 'Invalid prop "count" on : expected number, got ref (ref("oops")).', + 'Invalid prop "count" on : expected number, got ref("oops").', ) expect(root.querySelector('.summary')).toBeNull() })