diff --git a/.changeset/social-lands-talk.md b/.changeset/social-lands-talk.md new file mode 100644 index 00000000..7c7841fc --- /dev/null +++ b/.changeset/social-lands-talk.md @@ -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'), +}); +``` diff --git a/examples/basic/package.json b/examples/basic/package.json index d0af04aa..f1d2eb8b 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -5,6 +5,7 @@ "type": "module", "dependencies": { "@clack/prompts": "workspace:*", + "arktype": "^2.2.0", "picocolors": "^1.0.0", "jiti": "^1.17.0" }, diff --git a/examples/basic/standard-schema-validation.ts b/examples/basic/standard-schema-validation.ts new file mode 100644 index 00000000..6438dba2 --- /dev/null +++ b/examples/basic/standard-schema-validation.ts @@ -0,0 +1,35 @@ +import { setTimeout } from 'node:timers/promises'; +import { isCancel, note, text } from '@clack/prompts'; +import { type } from 'arktype'; + +async function main() { + 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); diff --git a/examples/basic/text-validation.ts b/examples/basic/text-validation.ts index 7b928e4c..4cf3f5d9 100644 --- a/examples/basic/text-validation.ts +++ b/examples/basic/text-validation.ts @@ -37,4 +37,4 @@ async function main() { await setTimeout(1000); } -main().catch(console.error); +await main().catch(console.error); diff --git a/packages/core/package.json b/packages/core/package.json index 9f25b0cc..0359b105 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -60,6 +60,7 @@ "sisteransi": "^1.0.5" }, "devDependencies": { + "arktype": "^2.2.0", "vitest": "^3.2.4" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 478095b8..4383a3df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index c48a5181..d95442e0 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -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> { render(this: Omit): 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. + */ + validate?: Validate | undefined; input?: Readable; output?: Writable; signal?: AbortSignal; @@ -230,7 +238,7 @@ export default class Prompt { 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'; diff --git a/packages/core/src/utils/standard-schema.ts b/packages/core/src/utils/standard-schema.ts new file mode 100644 index 00000000..a1103935 --- /dev/null +++ b/packages/core/src/utils/standard-schema.ts @@ -0,0 +1,78 @@ +// https://standardschema.dev/schema + +/** The Standard Schema interface. */ +export interface StandardSchemaV1 { + /** The Standard Schema properties. */ + readonly "~standard": StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + /** The Standard Schema properties interface. */ + export interface Props { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Validates unknown input values. */ + readonly validate: ( + value: unknown, + options?: StandardSchemaV1.Options | undefined, + ) => Result | Promise>; + /** Inferred types associated with the schema. */ + readonly types?: Types | undefined; + } + + /** The result interface of the validate function. */ + export type Result = SuccessResult | FailureResult; + + /** The result interface if validation succeeds. */ + export interface SuccessResult { + /** The typed output value. */ + readonly value: Output; + /** A falsy value for `issues` indicates success. */ + readonly issues?: undefined; + } + + export interface Options { + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + } + + /** The result interface if validation fails. */ + export interface FailureResult { + /** The issues of failed validation. */ + readonly issues: ReadonlyArray; + } + + /** The issue interface of the failure output. */ + export interface Issue { + /** The error message of the issue. */ + readonly message: string; + /** The path of the issue, if any. */ + readonly path?: ReadonlyArray | undefined; + } + + /** The path segment interface of the issue. */ + export interface PathSegment { + /** The key representing a path segment. */ + readonly key: PropertyKey; + } + + /** The Standard Schema types interface. */ + export interface Types { + /** The input type of the schema. */ + readonly input: Input; + /** The output type of the schema. */ + readonly output: Output; + } + + /** Infers the input type of a Standard Schema. */ + export type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + + /** Infers the output type of a Standard Schema. */ + export type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; +} diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts new file mode 100644 index 00000000..d4cf3c95 --- /dev/null +++ b/packages/core/src/utils/validation.ts @@ -0,0 +1,35 @@ +import type { StandardSchemaV1 } from './standard-schema.js'; + +/** + * 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 = + | ((value: TValue | undefined) => string | Error | undefined) + | StandardSchemaV1; + +/** + * Runs the `validate()` option and normalizes the result + * @param validate - The validate option + * @param value - The user input + * @returns string | Error | undefined + */ +export function runValidation( + validate: Validate, + 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 remove any asynchronous logic.' + ); + } + return result.issues?.at(0)?.message; + } + return validate(value); +} diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index bc4fa5e6..89c1e37f 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -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'; @@ -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({ + 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({ - 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({ + 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({ - 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({ + 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({ + 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")'); + }); }); }); diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 78227000..9bc085f5 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -1,4 +1,5 @@ import { styleText } from 'node:util'; +import type { Validate } from '@clack/core'; import { AutocompletePrompt, settings } from '@clack/core'; import { type CommonOptions, @@ -70,10 +71,11 @@ interface AutocompleteSharedOptions 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; /** * Custom filter function to match options against the search input. diff --git a/packages/prompts/src/date.ts b/packages/prompts/src/date.ts index 5f87d197..4d0d0ca7 100644 --- a/packages/prompts/src/date.ts +++ b/packages/prompts/src/date.ts @@ -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 }; @@ -13,7 +13,13 @@ export interface DateOptions extends CommonOptions { initialValue?: Date; minDate?: Date; maxDate?: Date; - validate?: (value: Date | undefined) => string | Error | 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. + */ + validate?: Validate; } export const date = (opts: DateOptions) => { @@ -23,7 +29,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); @@ -33,7 +39,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() { diff --git a/packages/prompts/src/password.ts b/packages/prompts/src/password.ts index 94d96c71..3bdd5bdf 100644 --- a/packages/prompts/src/password.ts +++ b/packages/prompts/src/password.ts @@ -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'; @@ -18,10 +19,11 @@ export interface PasswordOptions extends CommonOptions { mask?: 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: string | undefined) => string | Error | undefined; + validate?: Validate; /** * When enabled it causes the input to be cleared if/when validation fails. diff --git a/packages/prompts/src/path.ts b/packages/prompts/src/path.ts index 921a9fda..6d87486b 100644 --- a/packages/prompts/src/path.ts +++ b/packages/prompts/src/path.ts @@ -1,5 +1,7 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; +import type { Validate } from '@clack/core'; +import { runValidation } from '@clack/core'; import { autocomplete } from './autocomplete.js'; import type { CommonOptions } from './common.js'; @@ -33,10 +35,11 @@ export interface PathOptions extends CommonOptions { initialValue?: string; /** - * A function that validates the given path. 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: string | undefined) => string | Error | undefined; + validate?: Validate; } /** @@ -71,7 +74,7 @@ export const path = (opts: PathOptions) => { return 'Please select a path'; } if (validate) { - return validate(value); + return runValidation(validate, value); } return undefined; }, diff --git a/packages/prompts/src/text.ts b/packages/prompts/src/text.ts index 46fae406..3ca9d975 100644 --- a/packages/prompts/src/text.ts +++ b/packages/prompts/src/text.ts @@ -1,4 +1,5 @@ import { styleText } from 'node:util'; +import type { Validate } from '@clack/core'; import { settings, TextPrompt } from '@clack/core'; import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; @@ -28,10 +29,11 @@ export interface TextOptions extends CommonOptions { initialValue?: 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: string | undefined) => string | Error | undefined; + validate?: Validate; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15cb5248..b8205001 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@clack/prompts': specifier: workspace:* version: link:../../packages/prompts + arktype: + specifier: ^2.2.0 + version: 2.2.0 jiti: specifier: ^1.17.0 version: 1.21.7 @@ -67,6 +70,9 @@ importers: specifier: ^1.0.5 version: 1.0.5 devDependencies: + arktype: + specifier: ^2.2.0 + version: 2.2.0 vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@20.19.39)(jiti@2.5.0) @@ -101,6 +107,12 @@ importers: packages: + '@ark/schema@0.56.0': + resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} + + '@ark/util@0.56.0': + resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -773,6 +785,12 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + arkregex@0.0.5: + resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} + + arktype@2.2.0: + resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -1854,6 +1872,12 @@ packages: snapshots: + '@ark/schema@0.56.0': + dependencies: + '@ark/util': 0.56.0 + + '@ark/util@0.56.0': {} + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -2433,6 +2457,16 @@ snapshots: argparse@2.0.1: {} + arkregex@0.0.5: + dependencies: + '@ark/util': 0.56.0 + + arktype@2.2.0: + dependencies: + '@ark/schema': 0.56.0 + '@ark/util': 0.56.0 + arkregex: 0.0.5 + array-union@2.1.0: {} assertion-error@2.0.1: {}