Skip to content
41 changes: 41 additions & 0 deletions library/src/actions/domain/domain.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 { domain, type DomainAction, type DomainIssue } from './domain.ts';

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

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

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

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

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

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

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

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

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

// General tests

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 common domains', () => {
expectNoActionIssue(action, [
'example.com',
'EXAMPLE.COM',
'sub.example.com',
'sub.sub2.example.co.uk',
]);
});
});

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

test('too long label (max 63 chars)', () => {
const tooLongLabel = 'a'.repeat(64);
const domainWithTooLongLabel = tooLongLabel + '.com';
expect(domainWithTooLongLabel.length).toBeGreaterThan(63);
expectActionIssue(action, baseIssue, ['a'.repeat(64) + '.com']);
});

test('too long TLD (max 63 chars)', () => {
const tooLongTLD = 'a'.repeat(64);
const domainWithTooLongTLD = `example.${tooLongTLD}`;
expect(domainWithTooLongTLD.length).toBeGreaterThan(63);
expectActionIssue(action, baseIssue, [domainWithTooLongTLD]);
});

test('too long domain (max 253 chars)', () => {
const label = 'a'.repeat(63);
const tooLongDomain = `${label}.${label}.${label}.${label}.${label}.com`;
expect(tooLongDomain.length).toBeGreaterThan(253);
expectActionIssue(action, baseIssue, [tooLongDomain]);
});

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

test('for missing TLD or single label', () => {
expectActionIssue(action, baseIssue, [
'localhost',
'intranet',
'example',
]);
});

test('for invalid label starts/ends', () => {
expectActionIssue(action, baseIssue, [
'-example.com',
'example-.com',
'.example.com',
'example..com',
'example.com.',
'example.c',
]);
});

test('for invalid characters and formats', () => {
expectActionIssue(action, baseIssue, [
'exa mple.com',
'example!.com',
'exa*mple.com',
'ex_amp.le.com',
'*.example.com',
'http://example.com',
]);
});
});
});
108 changes: 108 additions & 0 deletions library/src/actions/domain/domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { DOMAIN_REGEX } from '../../regex.ts';
import type {
BaseIssue,
BaseValidation,
ErrorMessage,
} from '../../types/index.ts';
import { _addIssue } from '../../utils/index.ts';

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

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

/**
* Creates a domain name validation action.
*
* Hint: Hint: ASCII-only validation.
* Internationalized domain names (IDN) not supported (including their Punycode form).
*
* @returns A domain action.
*/
export function domain<TInput extends string>(): DomainAction<
TInput,
undefined
>;

/**
* Creates a domain name validation action.
*
* @param message The error message.
*
* @returns A domain action.
*/
export function domain<
TInput extends string,
const TMessage extends ErrorMessage<DomainIssue<TInput>> | undefined,
>(message: TMessage): DomainAction<TInput, TMessage>;

// @__NO_SIDE_EFFECTS__
export function domain(
message?: ErrorMessage<DomainIssue<string>>
): DomainAction<string, ErrorMessage<DomainIssue<string>> | undefined> {
return {
kind: 'validation',
type: 'domain',
reference: domain,
expects: null,
async: false,
requirement: DOMAIN_REGEX,
message,
'~run'(dataset, config) {
if (dataset.typed && !this.requirement.test(dataset.value)) {
_addIssue(this, 'domain', dataset, config);
}
return dataset;
},
};
}
1 change: 1 addition & 0 deletions library/src/actions/domain/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './domain.ts';
1 change: 1 addition & 0 deletions library/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './cuid2/index.ts';
export * from './decimal/index.ts';
export * from './description/index.ts';
export * from './digits/index.ts';
export * from './domain/index.ts';
export * from './email/index.ts';
export * from './emoji/index.ts';
export * from './empty/index.ts';
Expand Down
10 changes: 10 additions & 0 deletions library/src/regex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ export const DECIMAL_REGEX: RegExp = /^[+-]?(?:\d*\.)?\d+$/u;
*/
export const DIGITS_REGEX: RegExp = /^\d+$/u;

/**
* [Domain name](https://en.wikipedia.org/wiki/Domain_name) regex.
*
* Hint: Hint: ASCII-only validation.
* Internationalized domain names (IDN) not supported (including their Punycode form).
*/
export const DOMAIN_REGEX: RegExp =
// eslint-disable-next-line regexp/require-unicode-regexp
/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;

/**
* [Email address](https://en.wikipedia.org/wiki/Email_address) regex.
*/
Expand Down
72 changes: 72 additions & 0 deletions website/src/routes/api/(actions)/domain/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
title: domain
description: Creates a domain validation action.
source: /actions/domain/domain.ts
contributors:
- yslpn
---

import { Link } from '@builder.io/qwik-city';
import { ApiList, Property } from '~/components';
import { properties } from './properties';

# domain

Creates a [domain name](https://en.wikipedia.org/wiki/Domain_name) validation action.

```ts
const Action = v.domain<TInput, TMessage>(message);
```

## Generics

- `TInput` <Property {...properties.TInput} />
- `TMessage` <Property {...properties.TMessage} />

## Parameters

- `message` <Property {...properties.message} />

### Explanation

With `domain` you can validate the formatting of a domain string.
If the input is not a valid domain, you can use `message` to customize the error message.

> Validates ASCII domains. Limits: 63 chars per label, 253 chars total. <Link href="https://en.wikipedia.org/wiki/Internationalized_domain_name">`Internationalized domain names`</Link> (IDN) not supported (including their Punycode form).
> If you need to validate a full URL (including protocol, path, query, etc.), use the <Link href="../url/">`url`</Link> action.

## Returns

- `Action` <Property {...properties.Action} />

## Examples

The following examples show how `domain` can be used.

### Domain schema

Schema to validate a domain.

```ts
const DomainSchema = v.pipe(
v.string(),
v.nonEmpty('Please enter your domain.'),
v.domain('The domain is badly formatted.')
);
```

## Related

The following APIs can be combined with `domain`.

### Schemas

<ApiList items={['any', 'custom', 'string']} />

### Methods

<ApiList items={['pipe']} />

### Utils

<ApiList items={['isOfKind', 'isOfType']} />
Loading