Tailwindcss 오픈소스 기여 과정기

계기
tailwindcss는 현재 프론트엔드 분야에서 스타일링을 위해 많이 쓰이는 라이브러리 중 하나입니다. 제가 맡은 프로젝트에서도 tailwindcss를 사용하고 있는데요. arbitrary의 calc 사용 중 공백을 이용한 가독성 증가를 원했습니다.
<div className="w-[calc(100px + 200px)]"></div>위와 같이 + 양옆에 공백을 주어 가독성을 높인 class를 작성하였습니다.
(css에서도 operator 사용 시 문법적으로 공백이 필요하다고 합니다. 참고)
하지만 tailwind에서는 className의 공백을 기준으로 파싱을 하기 때문에 w-[calc(100px , +, 200px)] 와 같이 독립적으로 분리가 되었고 결국 적용 되지 않았습니다.
w-[calc(100px+200px)] 와 같이 공백을 제거해 문법에 맞게 작성하면 가장 단순한 해결 방식이지만 분명 저와 비슷한 불편함을 겪는 개발자가 있을 것이고 tailwindcss에 기여해보면 좋지 않을까 생각하여서 도전해보았습니다.
과정
먼저 tailwindcss의 git을 클론 받았습니다. 많은 파일이 존재하였는데요. 먼저 테스트 파일을 보면 독립적으로 실행되는 과정을 따라갈 수 있지 않을까? 생각하여 arbitrary에 해당하는 테스트 파일을 확인하였습니다.
tests/arbitrary-values.test.js
// tests/arbitrary-values.test.js
it('should support slashes in arbitrary modifiers', () => {
let config = {
content: [{ raw: html`<div class="text-lg/[calc(50px/1rem)]"></div>` }],
}
return run('@tailwind utilities', config).then((result) => {
return expect(result.css).toMatchFormattedCss(css`
.text-lg\/\[calc\(50px\/1rem\)\] {
font-size: 1.125rem;
line-height: calc(50px / 1rem);
}
`)
})
})config 객체 안 content에 우리가 원하는 값들이 배열형태로 들어가는 것을 확인할 수 있습니다. 그럼 이 raw 안에 있는 class가 어떻게 파싱이 되어서 원하는 css로 변하게 될 수 있을까요? 아마 run을 실행하면 어떠한 함수가 실행이 되고 Promise를 리턴하는 것 같습니다. 이 run이 어떤 함수인지에 대해서 좀 더 살펴봅시다.
tests/util/run.js
//tests/util/run.js
export function run(input, config, plugin = tailwind) {
let { currentTestName } = expect.getState()
return postcss(plugin(config)).process(input, {
from: `${path.resolve(__filename)}?test=${currentTestName}`,
})
}plugin 이라는 함수에 config가 인자로 들어가고 그 반환값을 postcss로 받아서 처리하는 군요. 일반적으로 postcss는 js의 스타일을 css로 변경해 주는 툴 입니다. 저희는 변환하기 전 calc 내부의 공백을 제거하는 것이 목적이므로 plugin 내부에서 해결책을 찾을 수 있을거라 추측할 수 있습니다.
src/plugin.js
// src/plugin.js
module.exports = function tailwindcss(configOrPath) {
return {
postcssPlugin: 'tailwindcss',
plugins: [
env.DEBUG &&
function (root) {
console.log('\n')
console.time('JIT TOTAL')
return root
},
async function (root, result) {
// Use the path for the `@config` directive if it exists, otherwise use the
// path for the file being processed
configOrPath = findAtConfigPath(root, result) ?? configOrPath
let context = setupTrackingContext(configOrPath)
if (root.type === 'document') {
let roots = root.nodes.filter((node) => node.type === 'root')
for (const root of roots) {
if (root.type === 'root') {
await processTailwindFeatures(context)(root, result)
}
}
return
}
await processTailwindFeatures(context)(root, result)
},
env.DEBUG &&
function (root) {
console.timeEnd('JIT TOTAL')
console.log('\n')
return root
},
].filter(Boolean),
}
}이 부분을 해석하는데 조금 어려움이 있었는데요. 여기서는 plugins 배열을 반환하는데 그 안에는 여러 함수들이 존재합니다. 그 중 디버깅과는 관련없으니 제거를 하면 하나의 함수가 남겠군요. 저희는 Path와 관련된(url과 같은) config가 아니기에 configOrPath는 config와 같습니다.
이제 새로운 개념인 context가 나오는데요. 해당 부분을 hover해보면 다음과 같음을 알 수 있습니다.
context를 생성하는 함수를 반환하는 함수? 쯤으로 생각해도 좋을 것 같네요. 그럼 이 부분을 사용하는 processTailwindFeatures 을 찾아봅시다.
src/processTailwindFeatures.js
코드가 길어서 핵심 부분만 요약하면, getClassCandidates 함수에서 calc 내부의 공백을 제거하는 함수를 추가했습니다:
function removeSpacesInsideCalc(content) {
return content.replace(/calc\(([^)]*)\)/g, (_, p1) => `calc(${p1.replace(/\s+/g, '')})`)
}
function getClassCandidates(content, extractor, candidates, seen) {
if (!extractorCache.has(extractor)) {
extractorCache.set(extractor, new LRU({ maxSize: 25000 }))
}
for (let line of content.split('\n')) {
line = line.trim()
line = removeSpacesInsideCalc(line) // 해당 부분 추가
...
}
}이를 통해 class 후보를 확인하기 전 calc의 공백을 제거하여 하나의 클래스로 인식할 수 있게 하였고 테스트 코드를 작성하여 정상적으로 작동함을 확인하였습니다.
it('should support arbitrary calc values with spaces`', () => {
let config = {
content: [
{
raw: html`<div
class="w-[calc(100px + 200px)] h-[calc(3rem - 1rem)] text-lg/[calc(50px / 1rem)]"
></div>`,
},
],
}
return run('@tailwind utilities', config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.h-\[calc\(3rem-1rem\)\] {
height: 2rem;
}
.w-\[calc\(100px\+200px\)\] {
width: 300px;
}
.text-lg\/\[calc\(50px\/1rem\)\] {
font-size: 1.125rem;
line-height: calc(50px / 1rem);
}
`)
})
})후기
결론적으로는 reject 당했습니다 ㅎㅎ... tailwind 컨트리뷰터분이 친절하게 피드백을 주셨는데요! 결론적으로는 공백으로 클래스를 구분하는 것은 html 고유의 특성이므로 수정해서는 안되는것 같아요.
한편으로는 아쉬움이 많이 남지만 해당 과정에서 tailwind의 동작과정을 짧게나마 알아볼 수 있었고 유명한 개발자와 소통을 해볼 수 있었다는 뿌듯함이 있었던것 같습니다. 또한 해당 부분의 이슈들을 찾아보다 보니 자연스럽게 영어도 늘 수 있어서 재밌었습니다!