[NPM] Chalk 패키지 살펴보기
chalk 모듈은 터미널 콘솔에 찍히는 로그에 쉽게 색상등의 스타일링을 할 수 있도록 도와주는 패키지이다.
import chalk from 'chalk';
const log = console.log;
// Combine styled and normal strings
log(chalk.blue('Hello') + ' World' + chalk.red('!'));
// Compose multiple styles using the chainable API
log(chalk.blue.bgRed.bold('Hello world!'));
// Pass in multiple arguments
log(chalk.blue('Hello', 'World!', 'Foo', 'bar', 'biz', 'baz'));
chalk 패키지는 어떻게 스타일링을 수행하는지 소스 코드를 살펴보자. 중심 동작 설명에 불필요한 코드는 제거하여 설명하겠다.
살펴보기 전에..
터미널에서 스타일링을 하기 위해서는 ANSI escape code를 사용한다. 예를들어 아래와 같이 빨강색에 해당하는 31을 포함한 ANSI code를 원하는 문자열 앞 뒤로 붙여주게 된다면 색이 입혀 출력된다.
소스 코드
package.json
"type": "module",
"main": "./source/index.js",
"exports": "./source/index.js",
"imports": {
"#ansi-styles": "./source/vendor/ansi-styles/index.js",
"#supports-color": {
"node": "./source/vendor/supports-color/index.js",
"default": "./source/vendor/supports-color/browser.js"
}
},
"types": "./source/index.d.ts",
"scripts": {
"test": "xo && c8 ava && tsd",
"bench": "matcha benchmark.js"
}
우선 패키지의 package.json부터 살펴보자.
- type은 module로 ESM 패키지이다. chalk 4까지는 CJS였으나, 버전 5부터는 ESM을 지원한다.
- 따로 번들링 도구를 이용하여 빌드하지는 않으며, index.d.ts 파일을 직접 작성하여 export 해주고 있다.
- script로 testing과 benchmarking을 할 수 있으며, 해당 패키지의 testing에 대해선 글 마지막 단락에 설명하겠다.
- 한가지 살펴볼만한 것은 Subpath imports를 사용하고 있다는 점이다.
import ansiStyles from '#ansi-styles';
import supportsColor from '#supports-color';
Subpath imports를 이용하면 위와 같이 패키지 내에서 간결하게 모듈을 import 할 수 있다는 이점이 있다.
typescript의 alias 설정과 비슷하며, 간결하게 package.json에서 지정할 수 있다는 장점이 있다. 참고로 typescript 5.4부터 Subpath imports를 공식 지원하므로 한번 쯤 이용해 봐도 좋을 것 같다.
소스 코드 분석
chalk에서 가장 중요한 로직은 크게 source/index.js와 source/vendor/ansi-styles/index.js 두가지 파일에 존재한다.
chalk에서 각 스타일을 모으고, 적용하며 중첩 스타일링을 수행하는 동작은 위 다이어그램에 표기된 모듈과 함수를 통해 이루어진다. 우선 anti-styles 파일부터 살펴보자.
anti-styles
const styles = {
modifier: {
reset: [0, 0],
bold: [1, 22],
dim: [2, 22],
italic: [3, 23],
underline: [4, 24],
// ...
},
color: {
black: [30, 39],
red: [31, 39],
green: [32, 39],
yellow: [33, 39],
// ...
이 파일에는 위와 같이 styles Object에 Ansi escape code에 맞는 color code가 정의 되어있다.
이렇게 정의된 객체는 assembleStyles() 함수가 각 property를 가공해서 등록하는 과정을 거친다. 각 스타일의 ANSI code에 해당하는 { open, close } 객체를 생성하여 각 styleName에 매핑한 다음, style을 export한다.
function assembleStyles() {
for (const [groupName, group] of Object.entries(styles)) {
for (const [styleName, style] of Object.entries(group)) {
styles[styleName] = {
open: `\u001B[${style[0]}m`,
close: `\u001B[${style[1]}m`,
};
group[styleName] = styles[styleName];
}
Object.defineProperty(styles, groupName, {
value: group,
enumerable: false,
});
}
// ...
const ansiStyles = assembleStyles();
export default ansiStyles;
index.js
import ansiStyles from '#ansi-styles';
for (const [styleName, style] of Object.entries(ansiStyles)) {
styles[styleName] = {
get() {
const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY]);
Object.defineProperty(this, styleName, {value: builder});
return builder;
},
};
}
index.js에서는 ansiStyles import 한 다음, 반복문을 통해 styleName 각각에 createBuilder()를 이용하여 builder를 생성한다. 각 style의 open, close 값을 통해 styler를 생성한 다음 createStyler()를 통해 builder에 제공한다.
이때 createStyler()의 3번째 인자에 this[STYLER]를 받는데, 잘 기억해 두길 바란다.
const createBuilder = (self, _styler, _isEmpty) => {
const builder = (...arguments_) => applyStyle(builder, (arguments_.length === 1) ? ('' + arguments_[0]) : arguments_.join(' '));
// ...
builder[GENERATOR] = self;
builder[STYLER] = _styler;
builder[IS_EMPTY] = _isEmpty;
return builder;
};
createBuilder의 동작을 살펴보면, argument의 개수에 따라 applyStyle()함수를 적용한다. arguments_를 받아 색을 입힐 string을 array로 가져와(구조분해할당) 여러개라면 Array.join()문으로 합쳐주는 것을 볼 수 있다.
이때 builder[STYLER] = _styler; 를 통해 인수로 전달받은 _styler를 builder에 등록하는 과정을 볼 수 있다.
이 과정으로 만약 체이닝을 통해 새로운 중첩 스타일을 등록한다면, 이 경우 앞서 createStyler()에 제공된 styler의 parent에 등록되게 된다.
const createStyler = (open, close, parent) => {
let openAll;
let closeAll;
if (parent === undefined) {
openAll = open;
closeAll = close;
} else {
openAll = parent.openAll + open;
closeAll = close + parent.closeAll;
}
return {
open,
close,
openAll,
closeAll,
parent,
};
};
createStyler()는 parent 여부에 따라 다른 방법으로 openAll, closeAll을 반환한다.
const applyStyle = (self, string) => {
// ...
let styler = self[STYLER];
if (styler === undefined) {
return string;
}
const {openAll, closeAll} = styler;
if (string.includes('\u001B')) {
while (styler !== undefined) {
string = stringReplaceAll(string, styler.close, styler.open);
styler = styler.parent;
}
}
// for ES2015 template literal
const lfIndex = string.indexOf('\n');
if (lfIndex !== -1) {
string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
}
return openAll + string + closeAll;
};
builder는 applyStyle()을 통해 styler를 적용한다. 해당 함수를 살펴보면 styler가 중첩되지 않은 경우 바로 string을 반환하는 모습을 확인할 수 있다. 그리고 if (string.includes('\u001B')) 분기를 통해 중첩 여부를 파악하고, while 반복문을 통해 중첩된 스타일을 적용해 준다.
추가적으로 template literal로 작성된 string이 들어온다면, ('\n') 여부를 통해 따로 처리를 해준다.
styles Object 등록
const chalkFactory = options => {
const chalk = (...strings) => strings.join(' ');
applyOptions(chalk, options);
Object.setPrototypeOf(chalk, createChalk.prototype);
return chalk;
};
function createChalk(options) {
return chalkFactory(options);
}
Object.setPrototypeOf(createChalk.prototype, Function.prototype);
// createChalk의 prototype을 Function으로 변경
// 앞서 builder를 styles에 등록하는 로직
Object.defineProperties(createChalk.prototype, styles);
const chalk = createChalk();
// ...
export default chalk
최종적으로, style을 만드는 builder 로직이 담긴 styles 객체를 createChalk prototype에 등록해준 다음 default export한다. 따라서 앞선 가장 처음의 예제와 같이 스타일을 적용하고, 중첩해서도 적용할 수 있게 된다.
+ RGB Colors 기능
chalk는 기본적인 style 이외에도 RGB 컬러를 직접 입력하여 사용할 수 있도록 하는 기능도 제공한다.
// Use RGB colors in terminal emulators that support it.
log(chalk.rgb(123, 45, 67).underline('Underlined reddish color'));
log(chalk.hex('#DEADED').bold('Bold gray!'));
이 기능은 어떻게 구현되어있는지 간단하게 살펴보자.
// index.js
const usedModels = ['rgb', 'hex', 'ansi256'];
for (const model of usedModels) {
styles[model] = {
get() {
const {level} = this;
return function (...arguments_) {
const styler = createStyler(getModelAnsi(model, levelMapping[level], 'color', ...arguments_), ansiStyles.color.close, this[STYLER]);
return createBuilder(this, styler, this[IS_EMPTY]);
};
},
};
const bgModel = 'bg' + model[0].toUpperCase() + model.slice(1);
styles[bgModel] = {
get() {
const {level} = this;
return function (...arguments_) {
const styler = createStyler(getModelAnsi(model, levelMapping[level], 'bgColor', ...arguments_), ansiStyles.bgColor.close, this[STYLER]);
return createBuilder(this, styler, this[IS_EMPTY]);
};
},
};
}
앞서 비슷한 방식으로 rgb, hex, ansi256를 styles Object에 builder를 등록해준다. 추가된 부분은 this.level를 이용하여 getModelAnsi() 함수를 통해 ANSI code로 변환한다는 점이다. 자세하게 설명하면 길어지기에, 더 궁금하다면 해당 함수 소스코드를 살펴보면 좋을 것 같다.
테스팅
"test": "xo && c8 ava && tsd"
chalk는 3가지 패키지를 이용하여 각 영역을 linting 및 testing 한다.
- xo: Eslint wrapper. eslintrc 없이 package.json에서 rule을 정의할 수 있다.
- ava: Node.js test runner
- tsd: type definition 파일(.d.ts)의 테스팅
// test/chalk.js
test('support multiple arguments in base function', t => {
t.is(chalk('hello', 'there'), 'hello there');
});
test('style string', t => {
t.is(chalk.underline('foo'), '\u001B[4mfoo\u001B[24m');
t.is(chalk.red('foo'), '\u001B[31mfoo\u001B[39m');
t.is(chalk.bgRed('foo'), '\u001B[41mfoo\u001B[49m');
});
test('support applying multiple styles at once', t => {
t.is(chalk.red.bgGreen.underline('foo'), '\u001B[31m\u001B[42m\u001B[4mfoo\u001B[24m\u001B[49m\u001B[39m');
t.is(chalk.underline.red.bgGreen('foo'), '\u001B[4m\u001B[31m\u001B[42mfoo\u001B[49m\u001B[39m\u001B[24m');
});
test('support nesting styles', t => {
t.is(
chalk.red('foo' + chalk.underline.bgBlue('bar') + '!'),
'\u001B[31mfoo\u001B[4m\u001B[44mbar\u001B[49m\u001B[24m!\u001B[39m',
);
});
// source/index.test-d.ts
// -- Properties --
expectType<ColorSupportLevel>(chalk.level);
// -- Color methods --
expectType<ChalkInstance>(chalk.rgb(0, 0, 0));
expectType<ChalkInstance>(chalk.hex('#DEADED'));
// -- Complex --
expectType<string>(chalk.red.bgGreen.underline('foo'));
expectType<string>(chalk.underline.red.bgGreen('foo'));
// -- Complex template literal --
expectType<string>(chalk.underline``);
expectType<string>(chalk.red.bgGreen.bold`Hello {italic.blue ${name}}`);
위와같이 각 chalk의 동작과 type definition 파일에 대한 테스팅이 작성되어 있다.