Skip to content

Commit 83428ac

Browse files
florian-lefebvre43081jghostdevv
authored
feat: standard schema for validation (#543)
Co-authored-by: James Garbutt <43081j@users.noreply.github.com> Co-authored-by: Willow (GHOST) <ghostdevbusiness@gmail.com>
1 parent adb6af9 commit 83428ac

15 files changed

Lines changed: 365 additions & 78 deletions

File tree

.changeset/social-lands-talk.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@clack/prompts": minor
3+
"@clack/core": minor
4+
---
5+
6+
Adds support for Standard Schema validation
7+
8+
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.
9+
10+
Libraries following the [Standard Schema specification](https://github.com/standard-schema/standard-schema) are now natively supported. For example, using [Arktype](https://arktype.io/):
11+
12+
```diff
13+
import { text } from '@clack/prompts';
14+
import { type } from 'arktype';
15+
16+
const name = await text({
17+
message: 'Enter your email',
18+
+ validate: type('string.email').describe('Invalid email'),
19+
});
20+
```

examples/basic/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"type": "module",
66
"dependencies": {
77
"@clack/prompts": "workspace:*",
8+
"arktype": "^2.2.0",
89
"picocolors": "^1.0.0",
910
"jiti": "^1.17.0"
1011
},
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { setTimeout } from 'node:timers/promises';
2+
import { isCancel, note, text } from '@clack/prompts';
3+
import { type } from 'arktype';
4+
5+
console.clear();
6+
7+
// Example demonstrating the issue with initial value validation
8+
const name = await text({
9+
message: 'Enter your email',
10+
initialValue: 'aaa', // Invalid initial value without @
11+
validate: type('string.email').describe('Invalid email'),
12+
});
13+
14+
if (!isCancel(name)) {
15+
note(`Valid name: ${name}`, 'Success');
16+
}
17+
18+
await setTimeout(1000);
19+
20+
// Example with a valid initial value for comparison
21+
const validName = await text({
22+
message: 'Enter another email',
23+
initialValue: 'john.doe@example.com', // Valid initial value
24+
validate: type('string.email').describe('Invalid email'),
25+
});
26+
27+
if (!isCancel(validName)) {
28+
note(`Valid name: ${validName}`, 'Success');
29+
}
30+
31+
await setTimeout(1000);

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"sisteransi": "^1.0.5"
6161
},
6262
"devDependencies": {
63+
"arktype": "^2.2.0",
6364
"vitest": "^3.2.4"
6465
}
6566
}

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ export type { ClackState as State } from './types.js';
2424
export { block, getColumns, getRows, isCancel, wrapTextWithPrefix } from './utils/index.js';
2525
export type { ClackSettings } from './utils/settings.js';
2626
export { settings, updateSettings } from './utils/settings.js';
27+
export type { Validate } from './utils/validation.js';
28+
export { runValidation } from './utils/validation.js';

packages/core/src/prompts/prompt.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,20 @@ import {
1313
setRawMode,
1414
settings,
1515
} from '../utils/index.js';
16+
import type { Validate } from '../utils/validation.js';
17+
import { runValidation } from '../utils/validation.js';
1618

1719
export interface PromptOptions<TValue, Self extends Prompt<TValue>> {
1820
render(this: Omit<Self, 'prompt'>): string | undefined;
1921
initialValue?: any;
2022
initialUserInput?: string;
21-
validate?: ((value: TValue | undefined) => string | Error | undefined) | undefined;
23+
24+
/**
25+
* A function or a [Standard Schema](https://github.com/standard-schema/standard-schema)
26+
* that validates user input. If a custom function is given, you should return a `string` or `Error`
27+
* to show as a validation error, or `undefined` to accept the result.
28+
*/
29+
validate?: Validate<TValue> | undefined;
2230
input?: Readable;
2331
output?: Writable;
2432
signal?: AbortSignal;
@@ -230,7 +238,7 @@ export default class Prompt<TValue> {
230238

231239
if (key?.name === 'return' && this._shouldSubmit(char, key)) {
232240
if (this.opts.validate) {
233-
const problem = this.opts.validate(this.value);
241+
const problem = runValidation(this.opts.validate, this.value);
234242
if (problem) {
235243
this.error = problem instanceof Error ? problem.message : problem;
236244
this.state = 'error';
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// https://standardschema.dev/schema
2+
3+
/** The Standard Schema interface. */
4+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
5+
/** The Standard Schema properties. */
6+
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
7+
}
8+
9+
export declare namespace StandardSchemaV1 {
10+
/** The Standard Schema properties interface. */
11+
export interface Props<Input = unknown, Output = Input> {
12+
/** The version number of the standard. */
13+
readonly version: 1;
14+
/** The vendor name of the schema library. */
15+
readonly vendor: string;
16+
/** Validates unknown input values. */
17+
readonly validate: (
18+
value: unknown,
19+
options?: StandardSchemaV1.Options | undefined
20+
) => Result<Output> | Promise<Result<Output>>;
21+
/** Inferred types associated with the schema. */
22+
readonly types?: Types<Input, Output> | undefined;
23+
}
24+
25+
/** The result interface of the validate function. */
26+
export type Result<Output> = SuccessResult<Output> | FailureResult;
27+
28+
/** The result interface if validation succeeds. */
29+
export interface SuccessResult<Output> {
30+
/** The typed output value. */
31+
readonly value: Output;
32+
/** A falsy value for `issues` indicates success. */
33+
readonly issues?: undefined;
34+
}
35+
36+
export interface Options {
37+
/** Explicit support for additional vendor-specific parameters, if needed. */
38+
readonly libraryOptions?: Record<string, unknown> | undefined;
39+
}
40+
41+
/** The result interface if validation fails. */
42+
export interface FailureResult {
43+
/** The issues of failed validation. */
44+
readonly issues: ReadonlyArray<Issue>;
45+
}
46+
47+
/** The issue interface of the failure output. */
48+
export interface Issue {
49+
/** The error message of the issue. */
50+
readonly message: string;
51+
/** The path of the issue, if any. */
52+
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
53+
}
54+
55+
/** The path segment interface of the issue. */
56+
export interface PathSegment {
57+
/** The key representing a path segment. */
58+
readonly key: PropertyKey;
59+
}
60+
61+
/** The Standard Schema types interface. */
62+
export interface Types<Input = unknown, Output = Input> {
63+
/** The input type of the schema. */
64+
readonly input: Input;
65+
/** The output type of the schema. */
66+
readonly output: Output;
67+
}
68+
69+
/** Infers the input type of a Standard Schema. */
70+
export type InferInput<Schema extends StandardSchemaV1> = NonNullable<
71+
Schema['~standard']['types']
72+
>['input'];
73+
74+
/** Infers the output type of a Standard Schema. */
75+
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
76+
Schema['~standard']['types']
77+
>['output'];
78+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { StandardSchemaV1 } from './standard-schema.js';
2+
3+
/**
4+
* A function or [Standard Schema](https://github.com/standard-schema/standard-schema)
5+
* that validates user input. If a custom function is given, you should return a
6+
* `string` or `Error` to show as a validation error, or `undefined` to accept the result.
7+
*
8+
* @example Using arktype
9+
* ```ts
10+
* import { text } from '@clack/prompts';
11+
* import { type } from 'arktype';
12+
*
13+
* const name = await text({
14+
* message: 'Enter your name (letters only)',
15+
* validate: type('string.alpha').describe('Name can only contain letters'),
16+
* });
17+
* ```
18+
*
19+
* @example Custom validator
20+
* ```ts
21+
* import { text } from '@clack/prompts';
22+
*
23+
* const age = await text({
24+
* message: 'Enter your age:',
25+
* validate(value) {
26+
* if (!value) return 'Please enter a value';
27+
* const num = parseInt(value);
28+
* if (isNaN(num)) return 'Please enter a valid number';
29+
* if (num < 0 || num > 120) return 'Age must be between 0 and 120';
30+
* return undefined;
31+
* },
32+
* });
33+
* ```
34+
*/
35+
export type Validate<TValue> =
36+
| ((value: TValue | undefined) => string | Error | undefined)
37+
| StandardSchemaV1<TValue | undefined, unknown>;
38+
39+
/**
40+
* Runs the `validate()` option and normalizes the result
41+
* @param validate - The validate option
42+
* @param value - The user input
43+
* @returns the validation result
44+
*/
45+
export function runValidation<TValue>(
46+
validate: Validate<TValue>,
47+
value: TValue | undefined
48+
): string | Error | undefined {
49+
if ('~standard' in validate) {
50+
const result = validate['~standard'].validate(value);
51+
// https://standardschema.dev/schema#how-to-only-allow-synchronous-validation
52+
// TODO: https://github.com/bombshell-dev/clack/issues/92
53+
if (result instanceof Promise) {
54+
throw new TypeError(
55+
'Schema validation must be synchronous. Update `validate()` and remove any asynchronous logic.'
56+
);
57+
}
58+
return result.issues?.at(0)?.message;
59+
}
60+
return validate(value);
61+
}

0 commit comments

Comments
 (0)