Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions library/src/actions/isbn/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './isbn.ts';
41 changes: 41 additions & 0 deletions library/src/actions/isbn/isbn.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expectTypeOf, test } from 'vitest';
import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts';
import { isbn, type IsbnAction, type IsbnIssue } from './isbn.ts';

describe('isbn', () => {
describe('should return action object', () => {
test('with undefined message', () => {
type Action = IsbnAction<string, undefined>;
expectTypeOf(isbn<string>()).toEqualTypeOf<Action>();
expectTypeOf(isbn<string, undefined>(undefined)).toEqualTypeOf<Action>();
});

test('with string message', () => {
expectTypeOf(isbn<string, 'message'>('message')).toEqualTypeOf<
IsbnAction<string, 'message'>
>();
});

test('with function message', () => {
expectTypeOf(isbn<string, () => string>(() => 'message')).toEqualTypeOf<
IsbnAction<string, () => string>
>();
});
});

describe('should infer correct types', () => {
type Action = IsbnAction<string, undefined>;

test('of input', () => {
expectTypeOf<InferInput<Action>>().toEqualTypeOf<string>();
});

test('of output', () => {
expectTypeOf<InferOutput<Action>>().toEqualTypeOf<string>();
});

test('of issue', () => {
expectTypeOf<InferIssue<Action>>().toEqualTypeOf<IsbnIssue<string>>();
});
});
});
118 changes: 118 additions & 0 deletions library/src/actions/isbn/isbn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, test } from 'vitest';
import type { StringIssue } from '../../schemas/index.ts';
import { expectActionIssue, expectNoActionIssue } from '../../vitest/index.ts';
import { isbn, type IsbnAction, type IsbnIssue } from './isbn.ts';

describe('ISBN', () => {
describe('should return action object', () => {
const baseAction: Omit<IsbnAction<string, never>, 'message'> = {
kind: 'validation',
type: 'ISBN',
reference: isbn,
expects: null,
requirement: expect.any(Function),
async: false,
'~run': expect.any(Function),
};

test('with undefined message', () => {
const action: IsbnAction<string, undefined> = {
...baseAction,
message: undefined,
};
expect(isbn()).toStrictEqual(action);
expect(isbn(undefined)).toStrictEqual(action);
});

test('with string message', () => {
expect(isbn('message')).toStrictEqual({
...baseAction,
message: 'message',
} satisfies IsbnAction<string, string>);
});

test('with function message', () => {
const message = () => 'message';
expect(isbn(message)).toStrictEqual({
...baseAction,
message,
} satisfies IsbnAction<string, typeof message>);
});
});

describe('should return dataset without issues', () => {
const action = isbn();

test('for untyped inputs', () => {
const issues: [StringIssue] = [
{
kind: 'schema',
type: 'string',
input: null,
expected: 'string',
received: 'null',
message: 'message',
},
];
expect(
action['~run']({ typed: false, value: null, issues }, {})
).toStrictEqual({
typed: false,
value: null,
issues,
});
});

test('for ISBN-10', () => {
expectNoActionIssue(action, [
'4873118735',
'4-87311-873-5',
'020530902X',
'0-205-30902-X',
]);
});

test('for ISBN-13', () => {
expectNoActionIssue(action, [
'978-4873118734',
'9784873118734',
'978-4-87311-873-4',
]);
});
});

describe('should return dataset with issues', () => {
const action = isbn('message');
const baseIssue: Omit<IsbnIssue<string>, 'input' | 'received'> = {
kind: 'validation',
type: 'ISBN',
expected: null,
message: 'message',
requirement: expect.any(Function),
};

test('for empty strings', () => {
expectActionIssue(action, baseIssue, ['', ' ', '\n']);
});

test('for ISBN-10', () => {
expectActionIssue(action, baseIssue, [
'4873118736', // invalid check digit
'4 87311 873 6', // spaces instead of hyphens
'020530902x', // lowercase 'x'
'020530902 x', // lowercase 'x'
'020530902', // too short
'020530902XX', // too long
]);
});

test('for ISBN-13', () => {
expectActionIssue(action, baseIssue, [
'9784873118735', // invalid check digit
'978 4873118734', // spaces instead of hyphens
'978487311873', // too short
'97848731187345', // too long
]);
});
});
});
147 changes: 147 additions & 0 deletions library/src/actions/isbn/isbn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type {
BaseIssue,
BaseValidation,
ErrorMessage,
} from '../../types/index.ts';
import { _addIssue } from '../../utils/index.ts';

/**
* ISBN issue interface.
*/
export interface IsbnIssue<TInput extends string> extends BaseIssue<TInput> {
/**
* The issue kind.
*/
readonly kind: 'validation';
/**
* The issue type.
*/
readonly type: 'ISBN';
/**
* The expected property.
*/
readonly expected: null;
/**
* The received property.
*/
readonly received: `"${string}"`;
/**
* The validation function.
*/
readonly requirement: (input: string) => boolean;
}

/**
* ISBN action interface.
*/
export interface IsbnAction<
TInput extends string,
TMessage extends ErrorMessage<IsbnIssue<TInput>> | undefined,
> extends BaseValidation<TInput, TInput, IsbnIssue<TInput>> {
/**
* The action type.
*/
readonly type: 'ISBN';
/**
* The action reference.
*/
readonly reference: typeof isbn;
/**
* The expected property.
*/
readonly expects: null;
/**
* The validation function.
*/
readonly requirement: (input: string) => boolean;
/**
* The error message.
*/
readonly message: TMessage;
}

/**
* Creates An [ISBN](https://en.wikipedia.org/wiki/ISBN) validation action.
*
* @returns An ISBN validation action.
*/
export function isbn<TInput extends string>(): IsbnAction<TInput, undefined>;

/**
* Creates An [ISBN](https://en.wikipedia.org/wiki/ISBN) validation action.
*
* @param message The error message.
*
* @returns An ISBN validation action.
*/
export function isbn<
TInput extends string,
TMessage extends ErrorMessage<IsbnIssue<TInput>> | undefined,
>(message: TMessage): IsbnAction<TInput, TMessage>;

// @__NO_SIDE_EFFECTS__
export function isbn(
message?: ErrorMessage<IsbnIssue<string>>
): IsbnAction<string, ErrorMessage<IsbnIssue<string>> | undefined> {
return {
kind: 'validation',
type: 'ISBN',
reference: isbn,
async: false,
expects: null,
requirement(input) {
const replacedInput = input.replaceAll('-', '');
const isbn10Regex = /^\d{9}[\dX]$/u;
const isbn13Regex = /^\d{13}$/u;
if (isbn10Regex.test(replacedInput)) {
return validateISBN10(replacedInput);
} else if (isbn13Regex.test(replacedInput)) {
return validateISBN13(replacedInput);
}
return false;
},
message,
'~run'(dataset, config) {
if (dataset.typed && !this.requirement(dataset.value)) {
_addIssue(this, 'ISBN', dataset, config);
}
return dataset;
},
};
}

/**
* [Validates an ISBN-10](https://en.wikipedia.org/wiki/ISBN#ISBN-10_check_digits).
*
* @param input The input value.
*
* @returns `true` if the input is a valid ISBN-10, `false` otherwise.
*/
function validateISBN10(input: string): boolean {
const digits = input.split('').map((c) => (c === 'X' ? 10 : parseInt(c)));
let sum = 0;
for (let i = 0; i < 9; i++) {
sum += digits[i] * (10 - i);
}
const mod = sum % 11;
const checkDigit = mod === 0 ? 0 : 11 - mod;
return checkDigit === digits[9];
}

/**
* [Validates an ISBN-13](https://en.wikipedia.org/wiki/ISBN#ISBN-13_check_digit_calculation).
*
* @param input The input value.
*
* @returns `true` if the input is a valid ISBN-13, `false` otherwise.
*/
function validateISBN13(input: string): boolean {
const digits = input.split('').map((c) => parseInt(c));
let sum = 0;
for (let i = 0; i < 12; i++) {
sum += digits[i] * (i % 2 === 0 ? 1 : 3);
}
const mod = sum % 10;
const checkDigit = mod === 0 ? 0 : 10 - mod;
return checkDigit === digits[12];
}
9 changes: 9 additions & 0 deletions website/src/routes/api/(actions)/isbn/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: ISBN
contributors:
- ysknsid25
---

# ISBN

> The content of this page is not yet ready. Until then just use the [source code](https://github.com/fabian-hiller/valibot/blob/main/library/src/actions/isbn/isbn.ts).
1 change: 1 addition & 0 deletions website/src/routes/api/(schemas)/string/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ The following APIs can be combined with `string`.
'ip',
'ipv4',
'ipv6',
'isbn',
'isoDate',
'isoDateTime',
'isoTime',
Expand Down
1 change: 1 addition & 0 deletions website/src/routes/api/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
- [ip](/api/ip/)
- [ipv4](/api/ipv4/)
- [ipv6](/api/ipv6/)
- [isbn](/api/isbn/)
- [isoDate](/api/isoDate/)
- [isoDateTime](/api/isoDateTime/)
- [isoTime](/api/isoTime/)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Pipeline validation actions examine the input and, if the input does not meet a
'ip',
'ipv4',
'ipv6',
'isbn',
'isoDate',
'isoDateTime',
'isoTime',
Expand Down
Loading