-
-
Notifications
You must be signed in to change notification settings - Fork 293
feat: ISBN validation #1097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
fabian-hiller
merged 14 commits into
open-circle:main
from
ysknsid25:feat/add-isbn-validation
Nov 23, 2025
Merged
feat: ISBN validation #1097
Changes from 7 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
fe1902d
feat: ISBN validation
ysknsid25 bab17ab
Update library/src/actions/isbn/isbn.ts
ysknsid25 3153928
Update library/src/actions/isbn/isbn.ts
ysknsid25 1ac837c
Update library/src/actions/isbn/isbn.ts
ysknsid25 e3c1e98
feat: ISBN validation
ysknsid25 504b020
feat: ISBN validation
ysknsid25 725bb8a
Merge branch 'main' into feat/add-isbn-validation
fabian-hiller ef8b0e7
Update library/src/actions/isbn/isbn.ts
ysknsid25 2592e3c
feat: ISBN validation
ysknsid25 fa29f49
Merge branch 'main' into feat/add-isbn-validation
fabian-hiller e7a4f17
Refactor and improve new isbn action and update changelog
fabian-hiller ca902e6
Refactor ISBN regex constants and update validation logic
fabian-hiller 6c7f9c0
Add docs for new isbn action to website
fabian-hiller 6957e0d
Add beta annotation to JSDoc comment of isbn action
fabian-hiller File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './isbn.ts'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>>(); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ]); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
ysknsid25 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * | ||
| * @param message The error message. | ||
| * | ||
| * @returns An ISBN validation action. | ||
ysknsid25 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
ysknsid25 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| */ | ||
| export function isbn< | ||
| TInput extends string, | ||
| TMessage extends ErrorMessage<IsbnIssue<TInput>> | undefined, | ||
ysknsid25 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| >(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]; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.