CSS를 파싱, 생성, 최적화, 비교하는 3만 줄 규모의 OCaml 라이브러리 Cascade와, Tailwind 출력을 검증하기 위해 만든 구조적 CSS diff 도구를 소개합니다.
Thomas Gazagnaire :: OCaml로 만든 CSS 엔진
내 웹사이트는 셀 수 없을 만큼 여러 번 OCaml로 다시 작성했다(ocaml-cow부터 Canopy, 그리고 맞춤형 MirageOS unikernels까지). 이번에는 스타일링이 문제였다. 나는 Tailwind CSS를 사용하고 있고, 전체 파이프라인(Markdown에서 스타일이 적용된 HTML, 그리고 CSS까지)이 Node.js 의존성 없이 하나의 dune build가 되기를 원했다.
그것은 Tailwind의 CSS 생성을 OCaml로 포팅해야 한다는 뜻이었다. 포트가 올바른지 확인하려면 그 CSS 출력을 JavaScript 원본과 비교해야 했다. 기존 CSS diff 도구들은 유지보수가 중단되었거나 CSS 2/3에만 제한되어 있었고, Tailwind v4가 생성하는 @layer, 컨테이너 쿼리, 중첩, 최신 색 공간을 처리하는 도구는 없었다. 그래서 하나를 쓰기 시작했다. 그런데 그러려면 완전한 파서가 필요했다. 그것이 타입이 있는 AST와 최적화기로 커졌고, 결국 그 자체로도 유용해졌다.
그 결과물이 Cascade다. CSS를 파싱하고, 생성하고, 최적화하고, diff하는 30,000줄 규모의 OCaml 라이브러리다. 여기에 포함된 도구 중 하나가 cssdiff로, 구조적 CSS 비교 도구다. Cascade는 순수 OCaml이므로 js_of_ocaml을 통해 JavaScript로 컴파일해 브라우저에서 실행할 수 있다. 직접 시도해 보거나, 아래 예시 중 하나를 골라 보라.
왼쪽
오른쪽
예시:
Cascade는 CSS 문자열을 타입이 있는 AST로 파싱하고, OCaml 코드에서 같은 AST를 구성할 수 있는 작은 eDSL도 제공한다. 이 AST로부터 렌더링(보기 좋게 출력하거나 축소), 최적화(중복 제거와 규칙 병합), 그리고 두 스타일시트의 구조적 diff를 수행할 수 있다. 파서는 수작업으로 작성한 재귀 하강 방식이며(Claude에게 손이 있을까?), CSS Syntax Level 3부터 Level 4 and 5까지(현대적인 선택자, 색 공간, @layer, 컨테이너 쿼리, 중첩)를 다룬다.
let css = Cascade.Css.of_string {|
.btn {
display: inline-block;
background-color: #3b82f6;
color: white;
padding: 0.5rem;
}
.btn {
background-color: #2563eb;
}
|}
파싱. 입력에는 중복된 .btn 규칙이 있다(생성된 CSS에서 흔하다). 파서는 둘 다 보존하고 타입이 있는 AST를 반환한다.
open Cascade.Css
let btn = Selector.class_ "btn"
let rules =
[ rule ~selector:btn
[ display Inline_block
; background_color (hex "#3b82f6")
; color (hex "#ffffff")
; padding (Rem 0.5) ]
; rule ~selector:btn
[ background_color (hex "#2563eb") ] ]
생성. 같은 두 규칙을 OCaml 값으로 구성했다. dsiplay 같은 오타는 컴파일 오류가 된다. padding에 색을 넘기면 타입 오류가 난다.
.btn {
display: inline-block;
background-color: #3b82f6;
color: #fff;
padding: 0.5rem;
}
.btn {
background-color: #2563eb;
}
출력.Css.to_string (Css.v rules)는 AST를 다시 CSS로 렌더링한다. 두 규칙 모두 보존된다. 출력기는 white를 #fff로 줄인다.
.btn {
display: inline-block;
background-color: #2563eb;
color: #fff;
padding: 0.5rem;
}
최적화.Css.to_string ~optimize:true (Css.v rules)는 두 .btn 규칙을 병합하고, 마지막 background-color를 유지한다(캐스케이드 순서). !important를 존중하고, content 폴백 같은 의도적인 패턴도 보존한다.
.btn 예시는 몇 가지 속성만 사용하지만, 이 라이브러리에는 레이아웃(박스 모델, flexbox, grid), 시각 요소(타이포그래피, 변형, 애니메이션, 필터), 논리 속성을 아우르는 100개 이상의 타입 지정 생성자가 있다.
CSS 파서가 올바르다는 것을 어떻게 알 수 있을까? 보통의 답은 “명세를 읽어라”이고, W3C 테스트 스위트는 개별 기능의 파싱을 다룬다. 하지만 그런 테스트는 브라우저가 CSS를 올바르게 _적용_하는지를 확인하지, 도구가 그것을 안전하게 _변환_할 수 있는지는 확인하지 않는다. 캐스케이드 레이어를 가로질러 두 규칙을 병합하거나 속성 하나를 중복 제거해도 페이지의 모양이 바뀌지 않는다는 것을 어떻게 알 수 있을까?
내가 실제로 잘 작동한다고 느낀 한 가지 접근법은 기준 구현과의 차등 비교다. 예를 들어 Tailwind를 OCaml로 포팅할 때, 같은 입력에 대해 JavaScript Tailwind CLI를 실행한 뒤 OCaml 포트를 실행하고, 두 CSS 출력을 바이트 단위로 비교한다. 비교는 엄격하다. 같은 문자, 같은 순서, 같은 공백이어야 한다. “구조적으로 동등하다”는 식은 아니다(그렇게 하면 비교 도구 자체의 버그를 숨기게 된다).
출력이 달라질 때(개발 중에는 끊임없이 그렇다), 나는 어떤 규칙이 바뀌었고 어떻게 바뀌었는지 알아야 한다. 50,000줄짜리 문자열 diff는 읽을 수 없다. 그것이 바로 cssdiff의 용도다. “.mt-4 규칙에서 margin-top이 1rem에서 16px로 바뀌었다”라고 알려 주는 구조적 diff는 바로 행동으로 옮길 수 있다. 이것은 AST 수준에서 동작하므로, 10번째 줄에 있던 규칙이 200번째 줄로 이동해도 삭제 후 추가가 아니라 순서 변경으로 표시된다. @media, @layer, @supports, @container 블록도 처리한다. 위 데모에서 실행되는 도구가 바로 이것이다.
바이트 단위 일치라는 목표는 최적화기까지도 정직하게 만든다. 무해한 차이만 있는 “올바른” CSS를 만드는 것은 쉬울 수 있다(예를 들어 서로 다른 속성 순서나 다른 hex 대문자화). 하지만 그런 차이를 허용하면 diff 도구가 어떤 차이가 무해한지 알아야 하며, 바로 그런 종류의 미묘한 버그가 의미론적 오류를 숨긴다. 기준 출력과 정확히 일치시키면 그런 종류의 문제를 없앨 수 있다.
라이브러리에는 두 개의 CLI 도구가 포함되어 있다: cascade(CSS 파일 포맷팅, 축소, 최적화)와 cssdiff(구조적 비교).
brew install samoht/tap/cascade
또는 opam으로:
opam pin add cascade https://github.com/samoht/cascade.git
이 라이브러리는 OCaml 4.14 이상이 필요하다. README에는 전체 API 개요와 CSS 명세 지원 표가 있다.
나는 블로그에 필요해서 Cascade를 만들었다. 그런데 그 범위를 넘어 유용한 도구가 되었다. 이제 CSS를 생성하고, 파싱하고, 비교하는 모든 OCaml 프로젝트에는 문자열 조작 대신 사용할 수 있는 타입이 있는 대안이 생겼다. 소스 코드는 GitHub에 있다.