Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
21 changes: 21 additions & 0 deletions .changeset/social-lands-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@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 name (letters only)',
initialValue: 'John123', // Invalid initial value with numbers
+ validate: type('string.alpha').describe('Name can only contain letters'),
});
```
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 name (letters only)',
initialValue: 'John123', // Invalid initial value with numbers
validate: type('string.alpha').describe('Name can only contain letters'),
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
});

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 name (letters only)',
initialValue: 'JohnDoe', // Valid initial value
validate: type('string.alpha').describe('Name can only contain letters'),
});

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

await setTimeout(1000);
}

main().catch(console.error);
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
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';
7 changes: 5 additions & 2 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ 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;
validate?: Validate<TValue> | undefined;
input?: Readable;
output?: Writable;
signal?: AbortSignal;
Expand Down Expand Up @@ -230,7 +232,8 @@ 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
22 changes: 22 additions & 0 deletions packages/core/src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { StandardSchemaV1 } from '@standard-schema/spec';

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

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
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);
}
168 changes: 111 additions & 57 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,135 @@ 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.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';
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();

input.emit('keypress', '', { name: 'return' });
instance.value = 'invalid';
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')),
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();

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

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

instance.value = 'invalid';
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();

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

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

test('validates 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 = 'Invalid Value $$$';
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('error');
expect(instance.error).to.equal('Invalid value');
});
instance.value = 'invalid';

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'),
input.emit('keypress', '', { name: 'return' });

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

instance.value = 'VALID';
input.emit('keypress', '', { name: 'return' });
test('validates value with Error object', () => {
const instance = new Prompt({
input,
output,
render: () => 'foo',
validate: type.string.pipe((value) =>
value === 'valid' ? undefined : new Error('must be 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');
});
});
});
3 changes: 2 additions & 1 deletion 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 @@ -73,7 +74,7 @@ interface AutocompleteSharedOptions<Value> extends CommonOptions {
* A function 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
10 changes: 5 additions & 5 deletions packages/prompts/src/date.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { styleText } from 'node:util';
import type { DateFormat, State } from '@clack/core';
import { DatePrompt, settings } from '@clack/core';
import type { DateFormat, State, Validate } from '@clack/core';
import { DatePrompt, runValidation, settings } from '@clack/core';
import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';

export type { DateFormat };
Expand All @@ -13,7 +13,7 @@ export interface DateOptions extends CommonOptions {
initialValue?: Date;
minDate?: Date;
maxDate?: Date;
validate?: (value: Date | undefined) => string | Error | undefined;
validate?: Validate<Date>;
}

export const date = (opts: DateOptions) => {
Expand All @@ -23,7 +23,7 @@ export const date = (opts: DateOptions) => {
validate(value: Date | undefined) {
if (value === undefined) {
if (opts.defaultValue !== undefined) return undefined;
if (validate) return validate(value);
if (validate) return runValidation(validate, value);
return settings.date.messages.required;
}
const iso = (d: Date) => d.toISOString().slice(0, 10);
Expand All @@ -33,7 +33,7 @@ export const date = (opts: DateOptions) => {
if (opts.maxDate && iso(value) > iso(opts.maxDate)) {
return settings.date.messages.beforeMax(opts.maxDate);
}
if (validate) return validate(value);
if (validate) return runValidation(validate, value);
return undefined;
},
render() {
Expand Down
3 changes: 2 additions & 1 deletion packages/prompts/src/password.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 { PasswordPrompt, settings } from '@clack/core';
import { type CommonOptions, S_BAR, S_BAR_END, S_PASSWORD_MASK, symbol } from './common.js';

Expand All @@ -21,7 +22,7 @@ export interface PasswordOptions extends CommonOptions {
* A function that validates user input. Return a `string` or `Error` to show as a
* validation error, or `undefined` to accept the result.
*/
validate?: (value: string | undefined) => string | Error | undefined;
validate?: Validate<string>;

/**
* When enabled it causes the input to be cleared if/when validation fails.
Expand Down
Loading
Loading