Skip to content
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

add slug action and its tests #910

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export * from './regex/index.ts';
export * from './returns/index.ts';
export * from './safeInteger/index.ts';
export * from './size/index.ts';
export * from './slug/index.ts';
export * from './someItem/index.ts';
export * from './sortItems/index.ts';
export * from './startsWith/index.ts';
Expand Down
1 change: 1 addition & 0 deletions library/src/actions/slug/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './slug.ts';
41 changes: 41 additions & 0 deletions library/src/actions/slug/slug.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 { slug, type SlugAction, type SlugIssue } from './slug.ts';

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

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

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

describe('should infer correct types', () => {
type Action = SlugAction<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<SlugIssue<string>>();
});
});
});
222 changes: 222 additions & 0 deletions library/src/actions/slug/slug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { describe, expect, test } from 'vitest';
import { SLUG_REGEX } from '../../regex.ts';
import type { StringIssue } from '../../schemas/index.ts';
import { expectActionIssue, expectNoActionIssue } from '../../vitest/index.ts';
import { slug, type SlugAction, type SlugIssue } from './slug.ts';

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

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

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

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

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

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

test('for valid words', () => {
expectNoActionIssue(action, [
'a',
'z',
'0',
'9',
'az',
'09',
'120',
'abc129',
'968foo',
'foo135bar',
'357ace642',
'collection',
]);
});

test('for valid words separated by valid separators', () => {
expectNoActionIssue(action, [
'a-a',
'a_a',
'z-z',
'z_z',
'0-0',
'0_0',
'9-9',
'9_9',
'az-az',
'az_az',
'09-09',
'09_09',
'120-120',
'120_120',
'abc129-abc129',
'abc129_abc129',
'968foo-968foo',
'968foo_968foo',
'foo135bar-foo135bar',
'foo135bar_foo135bar',
'357ace642-357ace642',
'357ace642_357ace642',
'this-that-other-outre-collection',
]);
});
});

describe('should return dataset with issues', () => {
const action = slug('message');
const baseIssue: Omit<SlugIssue<string>, 'input' | 'received'> = {
kind: 'validation',
type: 'slug',
expected: null,
message: 'message',
requirement: SLUG_REGEX,
};

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

test('for strings containing invalid characters', () => {
expectActionIssue(action, baseIssue, [
// rfc3986 valid characters
'A',
'Z',
'.',
'~',
// rfc3986 reserved characters
':',
'/',
'?',
'#',
'[',
']',
'@',
'!',
'$',
'&',
"'",
'(',
')',
'*',
'+',
',',
';',
'=',
// characters that occupy more than one byte
'é',
'❤️',
// URL-encoded
'%40', // '@'
'%20', // ' '
'%2F%C3%A9', // 'é'
// strings containing at least one invalid character
'helloWorld',
'café',
// regex specific tests
'a-A',
'a-Z',
'a-.',
'a-~',
'a-:',
'a-/',
'a-?',
'a-#',
'a-[',
'a-]',
'a-@',
'a-!',
'a-$',
'a-&',
"a-'",
'a-(',
'a-)',
'a-*',
'a-+',
'a-,',
'a-;',
'a-=',
'a-é',
'a-❤️',
'a-%40',
'a-%20',
'a-%2F%C3%A9',
]);
});

test('for invalid separators', () => {
expectActionIssue(action, baseIssue, [
'hello world',
'hello+world',
'hello%20world',
// consecutive valid separator characters
'hello--world',
'hello__world',
'hello-_world',
'hello_-world',
]);
});

test('for strings that start or end with separators', () => {
expectActionIssue(action, baseIssue, [
'-hello',
'_hello',
'-123',
'_123',
'hello-',
'hello_',
'123-',
'123_',
'-hello-',
'_hello_',
]);
});
});
});
101 changes: 101 additions & 0 deletions library/src/actions/slug/slug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { SLUG_REGEX } from '../../regex.ts';
import type {
BaseIssue,
BaseValidation,
ErrorMessage,
} from '../../types/index.ts';
import { _addIssue } from '../../utils/index.ts';

/**
* Slug issue type.
*/
export interface SlugIssue<TInput extends string> extends BaseIssue<TInput> {
/**
* The issue kind.
*/
readonly kind: 'validation';
/**
* The issue type.
*/
readonly type: 'slug';
/**
* The expected property.
*/
readonly expected: null;
/**
* The received property.
*/
readonly received: `"${string}"`;
/**
* The slug regex.
*/
readonly requirement: RegExp;
}

/**
* Slug action type.
*/
export interface SlugAction<
TInput extends string,
TMessage extends ErrorMessage<SlugIssue<TInput>> | undefined,
> extends BaseValidation<TInput, TInput, SlugIssue<TInput>> {
/**
* The action type.
*/
readonly type: 'slug';
/**
* The action reference.
*/
readonly reference: typeof slug;
/**
* The expected property.
*/
readonly expects: null;
/**
* The slug regex.
*/
readonly requirement: RegExp;
/**
* The error message.
*/
readonly message: TMessage;
}

/**
* Creates a [slug](https://en.wikipedia.org/wiki/Clean_URL#Slug) validation action.
*
* @returns A slug action.
*/
export function slug<TInput extends string>(): SlugAction<TInput, undefined>;

/**
* Creates a [slug](https://en.wikipedia.org/wiki/Clean_URL#Slug) validation action.
*
* @param message The error message.
*
* @returns A slug action.
*/
export function slug<
TInput extends string,
const TMessage extends ErrorMessage<SlugIssue<TInput>> | undefined,
>(message: TMessage): SlugAction<TInput, TMessage>;

export function slug(
message?: ErrorMessage<SlugIssue<string>>
): SlugAction<string, ErrorMessage<SlugIssue<string>> | undefined> {
return {
kind: 'validation',
type: 'slug',
reference: slug,
async: false,
expects: null,
requirement: SLUG_REGEX,
message,
'~validate'(dataset, config) {
if (dataset.typed && !this.requirement.test(dataset.value)) {
_addIssue(this, 'Slug', dataset, config);
}
return dataset;
},
};
}
5 changes: 5 additions & 0 deletions library/src/regex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,8 @@ export const ULID_REGEX: RegExp = /^[\da-hjkmnp-tv-z]{26}$/iu;
*/
export const UUID_REGEX: RegExp =
/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/iu;

/**
* [Slug](https://en.wikipedia.org/wiki/Clean_URL#Slug) regex.
*/
export const SLUG_REGEX: RegExp = /^[\da-z]+(?:[-_][\da-z]+)*$/u;