File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
/**
* @fox 입력 검증기 — Angular `Validators`의 계약을 그대로 옮겼다.
*
* 검증기는 **값을 받아 통과면 `null`, 아니면 오류 객체**를 돌려주는 순수 함수다. 오류 객체의
* 키가 곧 오류의 이름이고(`{ minlength: … }`), 값에는 문구를 만드는 데 필요한 맥락을 담는다
* (`{ requiredLength, actualLength }`). Angular가 이 모양을 쓰는 이유가 여기서도 그대로다 —
* **문구를 검증기가 정하지 않기 때문에** 같은 규칙을 화면마다 다른 말로 안내할 수 있다.
* 기본 문구는 `fox-validation-messages.ts`가 따로 갖는다.
*
* Angular와 다른 점은 하나다: 인자가 `AbstractControl`이 아니라 문자열이다. @fox에는 폼 모델이
* 없고 검증 대상이 입력 칸의 값 하나뿐이라, 컨트롤 객체를 두면 감싸는 비용만 늘어난다.
*
* `required`를 뺀 모든 검증기는 **빈 값을 통과시킨다**(Angular와 같다) — "비어 있음"의 판정은
* `required` 하나가 맡아야 빈 칸에 오류가 두 개씩 뜨지 않는다.
*/
export type FoxValidationErrors = Record<string, unknown>;
/** 통과면 `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<FoxValidationErrors>((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);
}