Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
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.
*/
Comment thread
florian-lefebvre marked this conversation as resolved.
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