Skip to content
Open
20 changes: 20 additions & 0 deletions .changeset/social-lands-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@clack/prompts": minor
"@clack/core": minor
---

Adds support for Standard Schema validation

Prompts accept an optional `validate()` function to validate user input. While a function provides more flexibility and customization over your validation, it can be a bit verbose. To help solve this, there are libraries that provide schema-based validation to make shorthand and type-strict validation substantially easier.

Libraries following the [Standard Schema specification](https://github.com/standard-schema/standard-schema) are now natively supported. For example, using [Arktype](https://arktype.io/):

```diff
import { text } from '@clack/prompts';
import { type } from 'arktype';

const name = await text({
message: 'Enter your email',
+ validate: type('string.email').describe('Invalid email'),
});
```
1 change: 1 addition & 0 deletions examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"dependencies": {
"@clack/prompts": "workspace:*",
"arktype": "^2.2.0",
"picocolors": "^1.0.0",
"jiti": "^1.17.0"
},
Expand Down
35 changes: 35 additions & 0 deletions examples/basic/standard-schema-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { setTimeout } from 'node:timers/promises';
import { isCancel, note, text } from '@clack/prompts';
import { type } from 'arktype';

async function main() {
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
console.clear();

// Example demonstrating the issue with initial value validation
const name = await text({
message: 'Enter your email',
initialValue: 'aaa', // Invalid initial value without @
validate: type('string.email').describe('Invalid email'),
});

if (!isCancel(name)) {
note(`Valid name: ${name}`, 'Success');
}

await setTimeout(1000);

// Example with a valid initial value for comparison
const validName = await text({
message: 'Enter another email',
initialValue: 'john.doe@example.com', // Valid initial value
validate: type('string.email').describe('Invalid email'),
});

if (!isCancel(validName)) {
note(`Valid name: ${validName}`, 'Success');
}

await setTimeout(1000);
}

await main().catch(console.error);
2 changes: 1 addition & 1 deletion examples/basic/text-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ async function main() {
await setTimeout(1000);
}

main().catch(console.error);
await main().catch(console.error);
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@
"test": "vitest run"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
"fast-wrap-ansi": "^0.2.0",
"sisteransi": "^1.0.5"
},
"devDependencies": {
"arktype": "^2.2.0",
"vitest": "^3.2.4"
}
}
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ export type { ClackState as State } from './types.js';
export { block, getColumns, getRows, isCancel, wrapTextWithPrefix } from './utils/index.js';
export type { ClackSettings } from './utils/settings.js';
export { settings, updateSettings } from './utils/settings.js';
export type { Validate } from './utils/validation.js';
export { runValidation } from './utils/validation.js';
12 changes: 10 additions & 2 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,20 @@ import {
setRawMode,
settings,
} from '../utils/index.js';
import type { Validate } from '../utils/validation.js';
import { runValidation } from '../utils/validation.js';

export interface PromptOptions<TValue, Self extends Prompt<TValue>> {
render(this: Omit<Self, 'prompt'>): string | undefined;
initialValue?: any;
initialUserInput?: string;
validate?: ((value: TValue | undefined) => string | Error | undefined) | undefined;

/**
* A function or a [Standard Schema](https://github.com/standard-schema/standard-schema)
* that validates user input. Return a `string` or `Error` to show as a validation error,
* or `undefined` to accept the result.
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
*/
validate?: Validate<TValue> | undefined;
input?: Readable;
output?: Writable;
signal?: AbortSignal;
Expand Down Expand Up @@ -230,7 +238,7 @@ export default class Prompt<TValue> {

if (key?.name === 'return' && this._shouldSubmit(char, key)) {
if (this.opts.validate) {
const problem = this.opts.validate(this.value);
const problem = runValidation(this.opts.validate, this.value);
if (problem) {
this.error = problem instanceof Error ? problem.message : problem;
this.state = 'error';
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { StandardSchemaV1 } from '@standard-schema/spec';

/**
* Represents the `validate()` option. A function or a
* [Standard Schema](https://github.com/standard-schema/standard-schema)
* that validates user input. Return a `string` or `Error` to show as a
* validation error, or `undefined` to accept the result.
*/
export type Validate<TValue> =
Comment thread
florian-lefebvre marked this conversation as resolved.
| ((value: TValue | undefined) => string | Error | undefined)
| StandardSchemaV1<TValue | undefined, any>;
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated

/**
* Runs the `validate()` option and normalizes the result
* @param validate - The validate option
* @param value - The user input
* @returns string | Error | undefined
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
*/
export function runValidation<TValue>(
validate: Validate<TValue>,
value: TValue | undefined
): string | Error | undefined {
if ('~standard' in validate) {
const result = validate['~standard'].validate(value);
// https://standardschema.dev/schema#how-to-only-allow-synchronous-validation
// TODO: https://github.com/bombshell-dev/clack/issues/92
if (result instanceof Promise) {
throw new TypeError(
'Schema validation must be synchronous. Update `validate()` and get rid of any asynchronous logic.'
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
);
}
return result.issues?.at(0)?.message;
}
return validate(value);
}
152 changes: 94 additions & 58 deletions packages/core/test/prompts/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type } from 'arktype';
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as Prompt } from '../../src/prompts/prompt.js';
Expand Down Expand Up @@ -233,82 +234,117 @@ describe('Prompt', () => {
expect(instance.state).to.equal('cancel');
});

test('accepts invalid initial value', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
initialValue: 'invalid',
validate: (value) => (value === 'valid' ? undefined : 'must be valid'),
describe('function validation', () => {
test('accepts invalid initial value', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
initialValue: 'invalid',
validate: (value) => (value === 'valid' ? undefined : 'must be valid'),
});
instance.prompt();

expect(instance.state).to.equal('active');
expect(instance.error).to.equal('');
});
instance.prompt();

expect(instance.state).to.equal('active');
expect(instance.error).to.equal('');
});
test('validates value on return', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
validate: (value) => (value === 'valid' ? undefined : 'must be valid'),
});
instance.prompt();

test('validates value on return', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
validate: (value) => (value === 'valid' ? undefined : 'must be valid'),
});
instance.prompt();
instance.value = 'invalid';

instance.value = 'invalid';
input.emit('keypress', '', { name: 'return' });

input.emit('keypress', '', { name: 'return' });
expect(instance.state).to.equal('error');
expect(instance.error).to.equal('must be valid');
});

expect(instance.state).to.equal('error');
expect(instance.error).to.equal('must be valid');
});
test('validates value with Error object', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
validate: (value) => (value === 'valid' ? undefined : new Error('must be valid')),
});
instance.prompt();

test('validates value with Error object', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
validate: (value) => (value === 'valid' ? undefined : new Error('must be valid')),
instance.value = 'invalid';
input.emit('keypress', '', { name: 'return' });

expect(instance.state).to.equal('error');
expect(instance.error).to.equal('must be valid');
});
instance.prompt();

instance.value = 'invalid';
input.emit('keypress', '', { name: 'return' });
test('validates value with regex validation', () => {
const instance = new Prompt<string>({
input,
output,
render: () => 'foo',
validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'),
});
instance.prompt();

expect(instance.state).to.equal('error');
expect(instance.error).to.equal('must be valid');
});
instance.value = 'Invalid Value $$$';
input.emit('keypress', '', { name: 'return' });

test('validates value with regex validation', () => {
const instance = new Prompt<string>({
input,
output,
render: () => 'foo',
validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'),
expect(instance.state).to.equal('error');
expect(instance.error).to.equal('Invalid value');
});
instance.prompt();

instance.value = 'Invalid Value $$$';
input.emit('keypress', '', { name: 'return' });
test('accepts valid value with regex validation', () => {
const instance = new Prompt<string>({
input,
output,
render: () => 'foo',
validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'),
});
instance.prompt();

instance.value = 'VALID';
input.emit('keypress', '', { name: 'return' });

expect(instance.state).to.equal('error');
expect(instance.error).to.equal('Invalid value');
expect(instance.state).to.equal('submit');
expect(instance.error).to.equal('');
});
});

test('accepts valid value with regex validation', () => {
const instance = new Prompt<string>({
input,
output,
render: () => 'foo',
validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'),
describe('standard schema', () => {
test('accepts invalid initial value', () => {
const instance = new Prompt<string>({
input,
output,
render: () => 'foo',
initialValue: 'invalid',
validate: type("'valid'"),
});
instance.prompt();

expect(instance.state).to.equal('active');
expect(instance.error).to.equal('');
});
instance.prompt();

instance.value = 'VALID';
input.emit('keypress', '', { name: 'return' });
test('validates value on return', () => {
const instance = new Prompt<string>({
input,
output,
render: () => 'foo',
validate: type("'valid'"),
});
instance.prompt();

expect(instance.state).to.equal('submit');
expect(instance.error).to.equal('');
instance.value = 'invalid';

input.emit('keypress', '', { name: 'return' });

expect(instance.state).to.equal('error');
expect(instance.error).to.equal('must be "valid" (was "invalid")');
});
});
});
8 changes: 5 additions & 3 deletions packages/prompts/src/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { styleText } from 'node:util';
import type { Validate } from '@clack/core';
import { AutocompletePrompt, settings } from '@clack/core';
import {
type CommonOptions,
Expand Down Expand Up @@ -70,10 +71,11 @@ interface AutocompleteSharedOptions<Value> extends CommonOptions {
placeholder?: string;

/**
* A function that validates user input. Return a `string` or `Error` to show as a
* validation error, or `undefined` to accept the result.
* A function or a [Standard Schema](https://github.com/standard-schema/standard-schema)
* that validates user input. Return a `string` or `Error` to show as a validation error,
* or `undefined` to accept the result.
*/
validate?: (value: Value | Value[] | undefined) => string | Error | undefined;
validate?: Validate<Value | Value[]>;

/**
* Custom filter function to match options against the search input.
Expand Down
Loading
Loading