터미널에 ✓ 표시 어떻게 초록색으로 나옴?
어제 vitest에 PR 하나를 올렸다. 아직 머지를 기다리고 있지만,, 코드 기여는 처음이라 넘 뿌듯했다 👍
메인테이너 분이 todo 테스트에 대한 새로운 아이콘을 추가로 적용해보는 게 어떻냐고 제안하셔서 □ 아이콘을 추가했는데 그러던 중 터미널에 1 passed처럼 초록색을 입히는 방식이 궁금해졌다.

vitest는 tinyrainbow라는 라이브러리를 쓰고 있다.
import c from 'tinyrainbow' // color 약자
export const testPass: string = c.green(F_CHECK)
export const todo: string = c.dim(c.gray(F_TODO)) // 추가: 회색 + 투명도
라이브러리가 복잡할 줄 알았는데 150줄짜리 작은 코드였고, vitest 메인테이너 분들도 같이 관리하고 있는 것 같다.
#FFFFFF생각 많이 날거야
번들 파일의 2/3 라인을 차지하는 컬러맵이다. 헥사 코드일 줄 알았는데 튜플이 있어서 당황스럽다.
const colorsMap = {
reset: [0, 0],
bold: [1, 22, '\x1B[22m\x1B[1m'],
dim: [2, 22, '\x1B[22m\x1B[2m'],
italic: [3, 23],
underline: [4, 24],
inverse: [7, 27],
hidden: [8, 28],
strikethrough: [9, 29],
black: [30, 39],
red: [31, 39],
green: [32, 39],
yellow: [33, 39],
blue: [34, 39],
magenta: [35, 39],
cyan: [36, 39],
white: [37, 39],
gray: [90, 39],
bgBlack: [40, 49],
bgRed: [41, 49],
bgGreen: [42, 49],
bgYellow: [43, 49],
bgBlue: [44, 49],
bgMagenta: [45, 49],
bgCyan: [46, 49],
bgWhite: [47, 49],
blackBright: [90, 39],
redBright: [91, 39],
greenBright: [92, 39],
yellowBright: [93, 39],
blueBright: [94, 39],
magentaBright: [95, 39],
cyanBright: [96, 39],
whiteBright: [97, 39],
bgBlackBright: [100, 49],
bgRedBright: [101, 49],
bgGreenBright: [102, 49],
bgYellowBright: [103, 49],
bgBlueBright: [104, 49],
bgMagentaBright: [105, 49],
bgCyanBright: [106, 49],
bgWhiteBright: [107, 49],
} as const
ANSI 이스케이프 시퀀스?! 🤔
컴퓨터 초창기 시절, 서로 다른 터미널 장치들이 제각각의 방식으로 화면을 제어하던 혼란을 막기 위해 ANSI(미국 표준 협회) 에서 정한 표준 규약이다. [시작 코드, 종료 코드]의 형태로, 터미널에 글자를 뿌릴 때 '여기서부터는 빨간색으로 그려!', '커서를 왼쪽으로 3칸 옮겨!' 같은 제어 명령을 전달한다.
컬러 맵에는 이렇게 숫자 값만 적어놓고, 나중에 \x1B[${숫자_값}m 처럼 wrapping해서 사용한다.
예시
파란 바탕에 글씨가 나오는 bgBlue를 예시로 들어보자.
bgBlue: [44, 49], // [시작, 종료]
44와 49 사이의 안녕 만 파란 바탕에 나오게 되고, 이후에는 초기화된다.
const text = "안녕"
const bgBlueText = `\x1B[44m${text}\x1B[49m`
console.log(bgBlueText);
우리가 터미널에서 /clear 하면 화면이 비워지는 것도 운영체제가 실행하는 clear라는 실행 파일이 \x1B[2J (화면 전체 지우기), \x1B[H (커서 처음으로 이동) 같은 ANSI 이스케이프 시퀀스를 출력하기 때문일 것이다.
근데 이건 왜 값이 3개인데?
그런데 bold와 dim에만 뒤에 하나가 더 붙어있다.
readonly bold: readonly [1, 22, "\u001B[22m\u001B[1m"],
readonly dim: readonly [2, 22, "\u001B[22m\u001B[2m"],
그 이유는 bold, dim의 종료 코드가 똑같이 22m이라서,
둘을 중첩해서 사용할 때 bold만 초기화하고 싶어도 bold와 dim이 함께 리셋버리는 문제가 생기기 때문이다.
c.dim("hello " + c.bold("world") + " bye") // 이런 경우!
즉, 세 번째 값은 평소에는 쓰이지 않고 bold와 dim이 중첩되는 경우에 쓰인다. 내부에 있는 bold가 닫힐 때 두 가지를 모두 초기화하고 다시 dim만 적용하는 방식이다.
근데 색상도 종료 코드는 똑같은데?
그렇게 따지면 색상도 종료 코드가 똑같은데 왜 세 번째 값이 없을까?
cyan: [36, 39],
white: [37, 39],
색상도 종료할 때 같이 초기화되는 건 마찬가지다. 다만 색상을 중첩하기보단, 보통 색상 + 스타일 조합으로 사용하는 경우가 많아서 의도적으로 세 번째 replace 값을 추가하지 않았을 거라 생각했다. 그리고 cyan(white("안녕"))은 중첩해도 어차피 cyan이고, 종료해도 똑같이 기본 색으로 돌아간다.
코드 살펴보기
전체 코드는 여기서 볼 수 있다.
일단 이 라이브러리는 default export로 메인 함수 createColors()가 반환하는 colorsObject를 내보낸다. vitest에서 봤던 c.dim()에서 c가 바로 colorsObject이다.
타입 선언은 이렇게 돼있다.
// 호출 시그니처 타입으로, `open`, `close` 프로퍼티도 갖고 있는 함수 객체
export interface Formatter {
(input?: unknown): string
open: string
close: string
}
// colorsMap 키 집합
type ColorName = keyof typeof colorsMap
// 각 키에 대해 Formatter를 지정
type ColorsMethods = {
[Key in ColorName]: Formatter
}
// colorsObject : Colors
export type Colors = ColorsMethods & {
isColorSupported: boolean
reset: (input: unknown) => string
}
이외에도 getDefaultColors(), isSupported() 같은 함수도 내보낸다. 만약 터미널이 ANSI 색상을 지원하지 않거나 사용자 설정이 있다면 원래 색상의 문자를 그대로 렌더링하는 string 함수가 불리도록 한다.
// 가짜 포메터(?)
function string(str: unknown) {
return String(str)
}
string.open = ''
string.close = ''
// 색상 안입힐 때
export function getDefaultColors(): Colors {
const defaultColors = {
isColorSupported: false,
reset: string,
} as Colors
for (const name in colorsMap) {
defaultColors[name as 'reset'] = string // 포메터와 같은 타입의 string 함수 세팅
}
return defaultColors
}
formatter 함수: 클로저 활용하기
formatter는 컬러 맵에서 보았던 2가지 또는 3가지 값을 인자로 받는다.
const formatter = (open: string, close: string, replace = open) => {
const fn = (input: unknown) => {
const string = String(input)
// 텍스트 내부에 이미 종료 코드가 들어있는지 확인
const index = string.indexOf(close, open.length)
return ~index
? open + replaceClose(string, close, replace, index) + close
: open + string + close
}
fn.open = open
fn.close = close
return fn // 클로저 함수를 반환
}
formatter는 이렇게 사용하는데
export function createColors(): Colors {
const enabled = isSupported()
// ...
const colorsObject = {
isColorSupported: enabled,
} as Colors
// 튜플 값을 ANSI 이스케이프 시퀀스로 래핑
const wrap = (num: number) => `\x1B[${num}m`
for (const name in colorsMap) {
const formatterArgs = colorsMap[name as 'bold'] // 컬러 맵의 튜플 값
colorsObject[name as ColorName] = enabled // 색칠 가능하면
? formatter(
wrap(formatterArgs[0]), // 시작 코드
wrap(formatterArgs[1]), // 종료 코드
formatterArgs[2] // replace (bold, dim)
)
: string // 색상 없는 문자열
}
return colorsObject
}
로직을 보면 우리가 vitest에서 c('시작코드', '종료코드') 이런식으로 안부르고 c.yellow('hello')로 편하게 사용할 수 있게 미리 colorsObject의 키들을 돌면서 formatter 함수를 호출해놓는다.
결국 우리는 c.yellow()를 사용할 때 formatter 함수가 반환한 내부 클로저 함수 fn을 호출해 쓰는 것이다.
클로저는 외부 환경을 기억하고 더 오래 살아남는 내부 함수 정도로만 알고 있었는데, 이렇게 활용할 수 있구나 싶었다. fn은 formatter의 매개변수로 전달받은 open, close, replace를 클로저로 캡처하고 있기 때문에 나중에도 그 값들을 기억한다.
마치며
vitest 보다가 어쩌다가 여기까지 왔는지 모르겠지만,, 나름 재밌었다! 코드가 쉽지는 않았지만 어느 정도 이해할 수 있었다. 난 오픈소스에 별로 관심이 없었고 뭔가 기여한다는 게 무섭게 느껴졌는데 이번에 PR을 올리면서 이런 생각이 들었다.
앞으로는 ODD로 간다 ; (오픈 소스 주도 개발)
구조 설계부터 명확한 변수명, 좋은 패턴들, CI, 메인테이너의 리뷰까지 정말 배울 게 많은 것 같다.
함수랑 산악회 VDD 최고 👍❤️