/** * @fox 입력 검증기 — Angular `Validators`의 계약을 그대로 옮겼다. * * 검증기는 **값을 받아 통과면 `null`, 아니면 오류 객체**를 돌려주는 순수 함수다. 오류 객체의 * 키가 곧 오류의 이름이고(`{ minlength: … }`), 값에는 문구를 만드는 데 필요한 맥락을 담는다 * (`{ requiredLength, actualLength }`). Angular가 이 모양을 쓰는 이유가 여기서도 그대로다 — * **문구를 검증기가 정하지 않기 때문에** 같은 규칙을 화면마다 다른 말로 안내할 수 있다. * 기본 문구는 `fox-validation-messages.ts`가 따로 갖는다. * * Angular와 다른 점은 하나다: 인자가 `AbstractControl`이 아니라 문자열이다. @fox에는 폼 모델이 * 없고 검증 대상이 입력 칸의 값 하나뿐이라, 컨트롤 객체를 두면 감싸는 비용만 늘어난다. * * `required`를 뺀 모든 검증기는 **빈 값을 통과시킨다**(Angular와 같다) — "비어 있음"의 판정은 * `required` 하나가 맡아야 빈 칸에 오류가 두 개씩 뜨지 않는다. */ export type FoxValidationErrors = Record; /** 통과면 `null`, 아니면 오류 객체. */ export type FoxValidator = (value: string) => FoxValidationErrors | null; function isEmpty(value: string): boolean { return value.trim().length === 0; } /** 값이 비어 있으면 `{ required: true }`. */ 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) ); } /** "영문·숫자·특수문자 중 N종류 이상" — 비밀번호 규칙의 가장 흔한 형태다. */ 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 } }; }; } /** 같은 문자가 `max`회를 넘겨 연달아 오면 오류(`aaa`). */ 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; }; } /** 코드값이 잇따르는 문자가 `max`회를 넘기면 오류(`abcd`·`4321`). 오름·내림 둘 다 본다. */ 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; }; } /** * 지정한 문자열을 품고 있으면 오류. 비밀번호에 ID·이름을 넣지 못하게 하는 규칙이다. * * 값을 함수로도 받는 이유는 그 대상이 **다른 칸의 지금 값**이기 때문이다 — 배열로 굳혀 두면 * ID를 고친 뒤에도 옛 ID로 검사한다. */ 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 }); } /** * 여러 검증기를 하나로 합친다. **통과하지 못한 것들의 오류 객체를 전부 병합해** 돌려준다 * (Angular의 `Validators.compose`와 같다) — 첫 오류에서 멈추지 않아야 "10자 이상"과 * "2종류 이상"을 함께 안내할 수 있다. */ 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 { /** 기본 8. 시안이 더 길게 요구하면 그 값을 준다. */ minLength?: number; maxLength?: number; /** 영문·숫자·특수문자 중 최소 몇 종류를 섞을지. 기본 2. */ kinds?: number; /** 같은 문자 연속 허용 횟수. 주지 않으면 검사하지 않는다. */ maxRepeated?: number; /** 잇따르는 문자 허용 길이(`abc`). 주지 않으면 검사하지 않는다. */ maxSequential?: number; /** 비밀번호에 들어가면 안 되는 값(보통 ID·이름). 지금 값을 읽도록 함수로 줄 수 있다. */ forbidden?: string[] | (() => string[]); /** 참이면 비어 있는 것도 오류다. 수정 화면처럼 "비우면 유지"인 자리에서는 끈다. */ required?: boolean; } /** * 비밀번호 칸의 규칙 한 벌. `type="password"` 입력에 그대로 얹으라고 둔 조합이다 — * 규칙 자체는 위 검증기들이고, 이 함수는 자주 쓰는 묶음에 이름을 붙인 것뿐이다. */ 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); }