From 21e2768324287dc62ae5923d78cb5d286b44eb45 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:23:39 +0200 Subject: [PATCH 01/10] Add canonical email validator --- src/regex/isEmail.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/regex/isEmail.ts diff --git a/src/regex/isEmail.ts b/src/regex/isEmail.ts new file mode 100644 index 0000000..c09ba1d --- /dev/null +++ b/src/regex/isEmail.ts @@ -0,0 +1,31 @@ +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; + if (domainPart.startsWith('.') || domainPart.endsWith('.') || domainPart.includes('..')) 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) + ); +} From b2749e5ee096b7bc16d1236b725363fd00baa4e0 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:23:49 +0200 Subject: [PATCH 02/10] Add canonical phone validator --- src/regex/isPhone.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/regex/isPhone.ts diff --git a/src/regex/isPhone.ts b/src/regex/isPhone.ts new file mode 100644 index 0000000..f74c4a5 --- /dev/null +++ b/src/regex/isPhone.ts @@ -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; +} From 8552465429b3b9ffc41071b7e6dd53505624d9dd Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:24:02 +0200 Subject: [PATCH 03/10] Add canonical username validator --- src/regex/isUsername.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/regex/isUsername.ts diff --git a/src/regex/isUsername.ts b/src/regex/isUsername.ts new file mode 100644 index 0000000..de73d37 --- /dev/null +++ b/src/regex/isUsername.ts @@ -0,0 +1,5 @@ +const USERNAME_PATTERN = /^[A-Za-z0-9._-]{3,}$/u; + +export function isUsername(value: string): boolean { + return USERNAME_PATTERN.test(value); +} From a83b0ee28833a4ded8aa4ccc336c86992344a793 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:24:09 +0200 Subject: [PATCH 04/10] Add canonical HTTP URL validator --- src/regex/isHttpUrl.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/regex/isHttpUrl.ts diff --git a/src/regex/isHttpUrl.ts b/src/regex/isHttpUrl.ts new file mode 100644 index 0000000..550c612 --- /dev/null +++ b/src/regex/isHttpUrl.ts @@ -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; + } +} From bb6cd2efb2025b205a76641fe42cb341aadab7c6 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:24:18 +0200 Subject: [PATCH 05/10] Expose canonical regex validators --- src/regex/index.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/regex/index.ts b/src/regex/index.ts index 7034c16..dde1479 100644 --- a/src/regex/index.ts +++ b/src/regex/index.ts @@ -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'; From 148c56601b69f06f974c6876ea9785af23cf9a4a Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:24:34 +0200 Subject: [PATCH 06/10] Test canonical regex validators --- src/regex/regex.test.ts | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/regex/regex.test.ts b/src/regex/regex.test.ts index b3b4a8b..8ed9965 100644 --- a/src/regex/regex.test.ts +++ b/src/regex/regex.test.ts @@ -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); }); From 9149ea92dd507338c35ce50297f196e715ababf1 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:24:41 +0200 Subject: [PATCH 07/10] Remove obsolete regex patterns --- src/regex/patterns.ts | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 src/regex/patterns.ts diff --git a/src/regex/patterns.ts b/src/regex/patterns.ts deleted file mode 100644 index 61e3077..0000000 --- a/src/regex/patterns.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; -export const URL_PATTERN = /^https?:\/\/\S+$/iu; From 6eed10fa642ab50b8157c9ab5bc6508e7b2f610c Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:24:46 +0200 Subject: [PATCH 08/10] Add regex validator changeset --- .changeset/canonical-regex-validators.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/canonical-regex-validators.md diff --git a/.changeset/canonical-regex-validators.md b/.changeset/canonical-regex-validators.md new file mode 100644 index 0000000..494fd3d --- /dev/null +++ b/.changeset/canonical-regex-validators.md @@ -0,0 +1,5 @@ +--- +'@ankhorage/utility': minor +--- + +Replace ambiguous regex-like helpers with canonical email, phone, username, and HTTP URL validators. From e77500e6f877c7c170ecedf86633a2ab2770ec6c Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:25:56 +0200 Subject: [PATCH 09/10] Format email validator --- src/regex/isEmail.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/regex/isEmail.ts b/src/regex/isEmail.ts index c09ba1d..8016895 100644 --- a/src/regex/isEmail.ts +++ b/src/regex/isEmail.ts @@ -12,7 +12,13 @@ export function isEmail(value: string): boolean { const domainPart = normalized.slice(atIndex + 1); if (localPart.length > 64 || domainPart.length === 0) return false; if (!LOCAL_PART_PATTERN.test(localPart)) return false; - if (domainPart.startsWith('.') || domainPart.endsWith('.') || domainPart.includes('..')) return false; + if ( + domainPart.startsWith('.') || + domainPart.endsWith('.') || + domainPart.includes('..') + ) { + return false; + } const labels = domainPart.split('.'); if (labels.length < 2 || labels.some(isInvalidDomainLabel)) return false; From 1f974647a0e721b028a3fe3b0b9127664ce9d5a4 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:26:52 +0200 Subject: [PATCH 10/10] Align email validator formatting --- src/regex/isEmail.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/regex/isEmail.ts b/src/regex/isEmail.ts index 8016895..c4ed008 100644 --- a/src/regex/isEmail.ts +++ b/src/regex/isEmail.ts @@ -12,13 +12,10 @@ export function isEmail(value: string): boolean { const domainPart = normalized.slice(atIndex + 1); if (localPart.length > 64 || domainPart.length === 0) return false; if (!LOCAL_PART_PATTERN.test(localPart)) return false; - if ( - domainPart.startsWith('.') || - domainPart.endsWith('.') || - domainPart.includes('..') - ) { - 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;