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
5 changes: 5 additions & 0 deletions .changeset/canonical-regex-validators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ankhorage/utility': minor
---

Replace ambiguous regex-like helpers with canonical email, phone, username, and HTTP URL validators.
15 changes: 4 additions & 11 deletions src/regex/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import { EMAIL_PATTERN, URL_PATTERN } from './patterns.js';

export { EMAIL_PATTERN, URL_PATTERN };

export function isEmailLike(value: string): boolean {
return EMAIL_PATTERN.test(value);
}

export function isUrlLike(value: string): boolean {
return URL_PATTERN.test(value);
}
export { isEmail } from './isEmail.js';
export { isHttpUrl } from './isHttpUrl.js';
export { isPhone } from './isPhone.js';
export { isUsername } from './isUsername.js';
34 changes: 34 additions & 0 deletions src/regex/isEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const LOCAL_PART_PATTERN = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/u;
const DOMAIN_LABEL_PATTERN = /^[A-Za-z0-9-]+$/u;

export function isEmail(value: string): boolean {
const normalized = value.trim();
if (normalized.length === 0 || normalized.length > 254) return false;

const atIndex = normalized.indexOf('@');
if (atIndex <= 0 || atIndex !== normalized.lastIndexOf('@')) return false;

const localPart = normalized.slice(0, atIndex);
const domainPart = normalized.slice(atIndex + 1);
if (localPart.length > 64 || domainPart.length === 0) return false;
if (!LOCAL_PART_PATTERN.test(localPart)) return false;

const hasInvalidDomainShape =
domainPart.startsWith('.') || domainPart.endsWith('.') || domainPart.includes('..');
if (hasInvalidDomainShape) return false;

const labels = domainPart.split('.');
if (labels.length < 2 || labels.some(isInvalidDomainLabel)) return false;

return (labels.at(-1) ?? '').length >= 2;
}

function isInvalidDomainLabel(label: string): boolean {
return (
label.length === 0 ||
label.length > 63 ||
label.startsWith('-') ||
label.endsWith('-') ||
!DOMAIN_LABEL_PATTERN.test(label)
);
}
11 changes: 11 additions & 0 deletions src/regex/isHttpUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function isHttpUrl(value: string): boolean {
const normalized = value.trim();
if (normalized.length === 0 || /\s/u.test(normalized)) return false;

try {
const url = new URL(normalized);
return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname.length > 0;
} catch {
return false;
}
}
28 changes: 28 additions & 0 deletions src/regex/isPhone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const PHONE_CHARACTER_PATTERN = /^[+()\d\s.-]+$/u;

export function isPhone(value: string): boolean {
const normalized = value.trim();
if (normalized.length === 0 || !PHONE_CHARACTER_PATTERN.test(normalized)) return false;

const digitCount = (normalized.match(/\d/gu) ?? []).length;
if (digitCount < 7 || digitCount > 15) return false;

const plusCount = (normalized.match(/\+/gu) ?? []).length;
if (plusCount > 1 || (plusCount === 1 && !normalized.startsWith('+'))) return false;

return hasBalancedParentheses(normalized);
}

function hasBalancedParentheses(value: string): boolean {
let depth = 0;
for (const character of value) {
if (character === '(') {
if (depth !== 0) return false;
depth = 1;
} else if (character === ')') {
if (depth !== 1) return false;
depth = 0;
}
}
return depth === 0;
}
5 changes: 5 additions & 0 deletions src/regex/isUsername.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const USERNAME_PATTERN = /^[A-Za-z0-9._-]{3,}$/u;

export function isUsername(value: string): boolean {
return USERNAME_PATTERN.test(value);
}
2 changes: 0 additions & 2 deletions src/regex/patterns.ts

This file was deleted.

40 changes: 28 additions & 12 deletions src/regex/regex.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
import { expect, test } from 'bun:test';

import { EMAIL_PATTERN, isEmailLike, isUrlLike, URL_PATTERN } from './index.js';
import { isEmail, isHttpUrl, isPhone, isUsername } from './index.js';

test('recognizes practical email-like values', () => {
expect(isEmailLike('hello@example.com')).toBe(true);
expect(isEmailLike('hello@example')).toBe(false);
expect(isEmailLike('hello example.com')).toBe(false);
expect(EMAIL_PATTERN.test('team+dev@ankhorage.com')).toBe(true);
test('validates email addresses', () => {
expect(isEmail('hello@example.com')).toBe(true);
expect(isEmail(' team+dev@ankhorage.com ')).toBe(true);
expect(isEmail('hello@example')).toBe(false);
expect(isEmail('hello@-example.com')).toBe(false);
expect(isEmail('hello@exam_ple.com')).toBe(false);
expect(isEmail('hello@example.c')).toBe(false);
});

test('recognizes HTTP and HTTPS URL-like values', () => {
expect(isUrlLike('https://ankhorage.com')).toBe(true);
expect(isUrlLike('http://localhost:3000/path')).toBe(true);
expect(isUrlLike('ftp://ankhorage.com')).toBe(false);
expect(isUrlLike('https://ankhorage.com/has space')).toBe(false);
expect(URL_PATTERN.test('https://example.com?q=1')).toBe(true);
test('validates phone numbers', () => {
expect(isPhone('+41 79 123 45 67')).toBe(true);
expect(isPhone('(044) 123-45-67')).toBe(true);
expect(isPhone('123456')).toBe(false);
expect(isPhone('41 +79 123 45 67')).toBe(false);
expect(isPhone(')044( 123 45 67')).toBe(false);
});

test('validates usernames', () => {
expect(isUsername('fabio_123')).toBe(true);
expect(isUsername('a.b')).toBe(true);
expect(isUsername('ab')).toBe(false);
expect(isUsername('fabio g')).toBe(false);
});

test('validates HTTP and HTTPS URLs', () => {
expect(isHttpUrl('https://ankhorage.com')).toBe(true);
expect(isHttpUrl('http://localhost:3000/path')).toBe(true);
expect(isHttpUrl('ftp://ankhorage.com')).toBe(false);
expect(isHttpUrl('https://ankhorage.com/has space')).toBe(false);
});
Loading