Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes to the library will be documented in this file.
- Add `toBigint`, `toBoolean`, `toDate`, `toNumber` and `toString` transformation actions (pull request #1212)
- Add `examples` action to add example values to a schema (pull request #1199)
- Add `getExamples` method to extract example values from a schema (pull request #1199)
- Add `isbn` validation action to validate ISBN-10 and ISBN-13 strings (pull request #1097)
- Add exports for `RawCheckAddIssue`, `RawCheckContext`, `RawCheckIssueInfo`, `RawTransformAddIssue`, `RawTransformContext` and `RawTransformIssueInfo` types for better developer experience with `rawCheck` and `rawTransform` actions (pull request #1359)
- Change build step to tsdown

Expand Down
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>>();
});
});
});
140 changes: 140 additions & 0 deletions library/src/actions/isbn/isbn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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',
'4 87311 873 5',
'020530902X',
'0-205-30902-X',
'0471958697', // check digit 0
'0-306 40615-2', // mixed separators
]);
});

test('for ISBN-13', () => {
expectNoActionIssue(action, [
'978-4873118734',
'9784873118734',
'978-4-87311-873-4',
'978 4 87311 873 4',
'9790000000001', // 979 prefix
'9791843123391', // 979 prefix
'9783161484100', // check digit 0
'978-0 306 40615-7', // mixed separators
]);
});
});

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', // invalid check digit
'020530902x', // lowercase 'x'
'020530902 x', // lowercase 'x'
'020530902', // too short
'020530902XX', // too long
'X234567890', // X in wrong position
'12345X7890', // X in middle
]);
});

test('for ISBN-13', () => {
expectActionIssue(action, baseIssue, [
'9784873118735', // invalid check digit
'978 4873118735', // invalid check digit
'978487311873', // too short
'97848731187345', // too long
'978030640615X', // X not allowed
'9770000000001', // invalid prefix
'9800000000002', // invalid prefix
]);
});

test('for other invalid formats', () => {
expectActionIssue(action, baseIssue, [
'abc1234567890', // letters
'978abc0000000', // letters in ISBN-13
'12345', // too short for both
'97804739123456', // too long (14 digits)
]);
});
});
});
129 changes: 129 additions & 0 deletions library/src/actions/isbn/isbn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type {
BaseIssue,
BaseValidation,
ErrorMessage,
} from '../../types/index.ts';
import { _addIssue } from '../../utils/index.ts';
import { _isIsbn10, _isIsbn13 } 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;
}

/**
* ISBN separator regex.
*/
const ISBN_SEPARATOR_REGEX = /[- ]/gu;

/**
* ISBN-10 detection regex.
*/
const ISBN_10_DETECTION_REGEX = /^\d{9}[\dX]$/u;

/**
* ISBN-13 detection regex.
*/
const ISBN_13_DETECTION_REGEX = /^\d{13}$/u;

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

/**
* Creates an [ISBN](https://en.wikipedia.org/wiki/ISBN) action.
*
* @param message The error message.
*
* @returns An ISBN action.
*
* @beta
*/
export function isbn<
TInput extends string,
const 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.replace(ISBN_SEPARATOR_REGEX, '');
if (ISBN_10_DETECTION_REGEX.test(replacedInput)) {
return _isIsbn10(replacedInput);
} else if (ISBN_13_DETECTION_REGEX.test(replacedInput)) {
return _isIsbn13(replacedInput);
}
return false;
},
message,
'~run'(dataset, config) {
if (dataset.typed && !this.requirement(dataset.value)) {
_addIssue(this, 'ISBN', dataset, config);
}
return dataset;
},
};
}
24 changes: 24 additions & 0 deletions library/src/actions/isbn/utils/_isIsbn10.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'vitest';
import { _isIsbn10 } from './_isIsbn10.ts';

describe('_isIsbn10', () => {
test('should return true', () => {
expect(_isIsbn10('0306406152')).toBe(true);
expect(_isIsbn10('0451526538')).toBe(true);
expect(_isIsbn10('0007149689')).toBe(true);
expect(_isIsbn10('043942089X')).toBe(true);
expect(_isIsbn10('097522980X')).toBe(true);
expect(_isIsbn10('0684843285')).toBe(true);
expect(_isIsbn10('1566199093')).toBe(true);
});

test('should return false', () => {
expect(_isIsbn10('0306406153')).toBe(false);
expect(_isIsbn10('0451526539')).toBe(false);
expect(_isIsbn10('0007149680')).toBe(false);
expect(_isIsbn10('0439420891')).toBe(false);
expect(_isIsbn10('0975229801')).toBe(false);
expect(_isIsbn10('0684843286')).toBe(false);
expect(_isIsbn10('1566199094')).toBe(false);
});
});
17 changes: 17 additions & 0 deletions library/src/actions/isbn/utils/_isIsbn10.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* [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.
*
* @internal
*/
export function _isIsbn10(input: string): boolean {
const digits = input.split('').map((c) => (c === 'X' ? 10 : parseInt(c)));
let sum = 0;
for (let i = 0; i < 10; i++) {
sum += digits[i] * (10 - i);
}
return sum % 11 === 0;
}
24 changes: 24 additions & 0 deletions library/src/actions/isbn/utils/_isIsbn13.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'vitest';
import { _isIsbn13 } from './_isIsbn13.ts';

describe('_isIsbn13', () => {
test('should return true', () => {
expect(_isIsbn13('9780306406157')).toBe(true);
expect(_isIsbn13('9780451526533')).toBe(true);
expect(_isIsbn13('9780007149681')).toBe(true);
expect(_isIsbn13('9780439420891')).toBe(true);
expect(_isIsbn13('9780975229804')).toBe(true);
expect(_isIsbn13('9780684843285')).toBe(true);
expect(_isIsbn13('9781566199094')).toBe(true);
});

test('should return false', () => {
expect(_isIsbn13('9780306406158')).toBe(false);
expect(_isIsbn13('9780451526534')).toBe(false);
expect(_isIsbn13('9780007149682')).toBe(false);
expect(_isIsbn13('9780439420892')).toBe(false);
expect(_isIsbn13('9780975229805')).toBe(false);
expect(_isIsbn13('9780684843286')).toBe(false);
expect(_isIsbn13('9781566199095')).toBe(false);
});
});
Loading
Loading