export type FoxValidationErrors = Record; export type FoxValidator = (value: string) => FoxValidationErrors | null; function isEmpty(value: string): boolean { return value.trim().length === 0; } function required(value: string): FoxValidationErrors | null { return isEmpty(value) ? { required: true } : null; } function minLength(requiredLength: number): FoxValidator { return (value) => { if (isEmpty(value) || value.length >= requiredLength) { return null; } return { minlength: { requiredLength, actualLength: value.length } }; }; } function maxLength(requiredLength: number): FoxValidator { return (value) => { if (value.length <= requiredLength) { return null; } return { maxlength: { requiredLength, actualLength: value.length } }; }; } function pattern(requiredPattern: RegExp): FoxValidator { return (value) => { if (isEmpty(value) || requiredPattern.test(value)) { return null; } return { pattern: { requiredPattern: requiredPattern.source, actualValue: value } }; }; } function email(value: string): FoxValidationErrors | null { if (isEmpty(value) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { return null; } return { email: true }; } const CHARACTER_KIND_PATTERNS = { letter: /[a-zA-Z]/, digit: /\d/, symbol: /[^a-zA-Z0-9]/, } as const; export type FoxCharacterKind = keyof typeof CHARACTER_KIND_PATTERNS; function countCharacterKinds(value: string): FoxCharacterKind[] { return (Object.keys(CHARACTER_KIND_PATTERNS) as FoxCharacterKind[]).filter( (kind) => CHARACTER_KIND_PATTERNS[kind].test(value) ); } function characterKinds(requiredKinds: number): FoxValidator { return (value) => { if (isEmpty(value)) { return null; } const kinds = countCharacterKinds(value); if (kinds.length >= requiredKinds) { return null; } return { characterKinds: { requiredKinds, actualKinds: kinds.length, kinds } }; }; } function noRepeatedCharacters(max: number): FoxValidator { return (value) => { if (isEmpty(value)) { return null; } let run = 1; for (let i = 1; i < value.length; i += 1) { run = value[i] === value[i - 1] ? run + 1 : 1; if (run > max) { return { repeatedCharacters: { max, character: value[i] } }; } } return null; }; } function noSequentialCharacters(max: number): FoxValidator { return (value) => { if (isEmpty(value)) { return null; } let ascending = 1; let descending = 1; for (let i = 1; i < value.length; i += 1) { const step = value.charCodeAt(i) - value.charCodeAt(i - 1); ascending = step === 1 ? ascending + 1 : 1; descending = step === -1 ? descending + 1 : 1; if (ascending > max || descending > max) { return { sequentialCharacters: { max, at: i - max } }; } } return null; }; } function notContaining( forbidden: string[] | (() => string[]), options: { caseSensitive?: boolean } = {} ): FoxValidator { return (value) => { if (isEmpty(value)) { return null; } const list = typeof forbidden === 'function' ? forbidden() : forbidden; const haystack = options.caseSensitive ? value : value.toLowerCase(); for (const raw of list) { const needle = options.caseSensitive ? raw : raw.toLowerCase(); if (needle.length > 0 && haystack.includes(needle)) { return { notContaining: { forbidden: raw } }; } } return null; }; } function equalTo(other: () => string): FoxValidator { return (value) => (isEmpty(value) || value === other() ? null : { equalTo: true }); } function compose(validators: FoxValidator[]): FoxValidator { return (value) => { const merged = validators.reduce((acc, validate) => { const errors = validate(value); return errors ? { ...acc, ...errors } : acc; }, {}); return Object.keys(merged).length > 0 ? merged : null; }; } export const foxValidators = { required, minLength, maxLength, pattern, email, characterKinds, noRepeatedCharacters, noSequentialCharacters, notContaining, equalTo, compose, }; export interface FoxPasswordPolicy { minLength?: number; maxLength?: number; kinds?: number; maxRepeated?: number; maxSequential?: number; forbidden?: string[] | (() => string[]); required?: boolean; } export function foxPasswordValidator(policy: FoxPasswordPolicy = {}): FoxValidator { const { minLength: min = 8, maxLength: max, kinds = 2, maxRepeated, maxSequential, forbidden, required: isRequired = false, } = policy; const validators: FoxValidator[] = []; if (isRequired) validators.push(required); validators.push(minLength(min)); if (max !== undefined) validators.push(maxLength(max)); validators.push(characterKinds(kinds)); if (maxRepeated !== undefined) validators.push(noRepeatedCharacters(maxRepeated)); if (maxSequential !== undefined) validators.push(noSequentialCharacters(maxSequential)); if (forbidden !== undefined) validators.push(notContaining(forbidden)); return compose(validators); }