Shiki로 빌드 타임 코드 하이라이팅 구현하기
빌드 타임 하이라이팅으로 런타임 부담 없애기
들어가며
기술 블로그에서 코드 하이라이팅이 깨지면 독자의 시선이 내용보다 먼저 오류에 걸린다. TypeScript 제네릭이나 데코레이터 같은 복잡한 문법을 정확하게 표현하는 것이 중요해서 라이브러리 선택에 신경을 썼다. 이 글에서는 VS Code와 동일한 토크나이저를 사용하는 Shiki를 빌드 타임에 적용한 과정을 정리한다.
Shiki를 선택한 이유
코드 하이라이팅 라이브러리는 크게 두 종류다.
Prism/highlight.js: 정규식 기반이다. 가볍고 빠르다. 이 블로그도 처음엔 Prism을 썼는데, TypeScript 코드가 부정확하게 하이라이팅되는 경우를 몇 번 겪었다. 원인이 궁금해서 Prism 레포를 보니 2020년부터 열려 있는 이슈가 있었다.
https://github.com/PrismJS/prism/issues/2594
제네릭과 JSX 태그의 문법이 충돌해서 특정 패턴의 코드를 만나면 그 뒤의 하이라이팅이 풀리는 버그였다. 2020년에 임시 우회 PR이 머지됐지만 제목부터 "Temporary workaround"가 붙어 있고, 이슈는 머지 후에도 의도적으로 열린 채 남아 있다. 메인테이너도 코멘트에서 "정규식 기반 접근으로는 완벽히 고치기 어렵다"고 인정했다. Prism v1은 v2 개발 때문에 유지보수 모드로 돌아가 보안 패치만 받고 있어서, 근본 수정도 기대하기 어려운 상황이었다. 그래서 Shiki로 갈아탔다.
Shiki: TextMate 문법 기반이다. TextMate는 2004년에 나온 macOS 텍스트 에디터인데, 이 에디터가 만든 XML 기반 문법 정의 포맷이 이후 VS Code에 채택되면서 사실상 업계 표준이 됐다. Shiki는 VS Code와 동일한 grammar 파일을 써서 토큰화 결과가 동일하다. 정확도가 높고, VS Code 테마도 그대로 쓸 수 있다.
빌드 타임 하이라이팅
- Shiki는 정확하지만 약 1MB의 WASM 엔진과 언어별 grammar·테마를 전부 로드해야 해서 런타임에 돌리면 무겁다.
- 블로그 글은 내용이 빌드 시점에 고정되므로 매번 색칠할 필요가 없다.
- 빌드 타임에 색칠을 끝내고 독자에겐 결과 HTML만 내려주면 된다.
Next.js에서 이걸 구현하는 건 세 조각으로 나뉜다. getStaticProps 가 진입점으로 "언제" 할지(빌드 타임에)를 정하고, extractAndHighlightCodeBlocks 함수가 "무엇을 어떻게" 색칠할지(코드 블록 찾고 Shiki 호출)를 담당하고, 페이지 컴포넌트가 결과를 DOM에 주입한다.
getStaticProps는 Next.js가 빌드할 때 딱 한 번 실행하는 함수다. 여기서 색칠까지 끝내두면 그 결과가 정적 HTML에 담긴 채로 독자에게 전달된다.
export const getStaticProps: GetStaticProps = async ({ params }) => {
const postData = await getPostData(params.id);
const codeBlocks = await extractAndHighlightCodeBlocks(postData.content);
return { props: { postData, codeBlocks } };
};
실제 추출/하이라이팅 로직은 extractAndHighlightCodeBlocks 함수로 분리했다. 이 함수는 마크다운 원문을 스캔해서 각 코드 블록을 찾고, 각각 highlightCode를 호출한 결과를 block-${index} 키로 저장한 맵을 반환한다. 페이지 컴포넌트는 이 맵을 받아서 dangerouslySetInnerHTML로 렌더링한다. 단, Mermaid 다이어그램은 빌드 타임 처리 대상에서 빼고 클라이언트에서 별도 컴포넌트로 렌더링한다.
다크모드 대응
Shiki는 테마를 한 번에 하나만 적용할 수 있다. 다크모드를 지원하려면 두 가지 방법이 있다.
- CSS 변수로 색상 오버라이드
- 라이트/다크 HTML을 둘 다 생성
Shiki 테마의 색상 체계를 CSS 변수로 바꾸는 건 번거롭고 테마를 바꿀 때마다 같은 작업을 반복해야 한다. 반면 라이트/다크 HTML을 빌드 타임에 둘 다 생성해두면 Tailwind dark: 접두사로 간단히 토글할 수 있어서 2번을 선택했다.
function isValidLanguage(lang: string): boolean {
return /^[a-zA-Z0-9#+-]+$/.test(lang);
}
export async function highlightCode(code: string, language?: string): Promise<string> {
// 유효하지 않은 언어명은 "text"로 fallback
const safeLang = language && isValidLanguage(language) ? language : "text";
const lightHtml = await codeToHtml(code, {
lang: safeLang,
theme: "github-light",
});
const darkHtml = await codeToHtml(code, {
lang: safeLang,
theme: "github-dark",
});
return `
<div class="light-theme block dark:hidden">${lightHtml}</div>
<div class="dark-theme hidden dark:block">${darkHtml}</div>
`;
}
isValidLanguage는 언어명에 알파벳·숫자·-·#·+만 허용하는 1차 방어선이다. 이상한 값이 들어오면 "text"로 fallback해서 Shiki가 에러를 던지기 전에 막는다. HTML이 두 배가 되지만 빌드 타임에 생성되므로 런타임 비용은 없다.
커스텀 CodeBlock 컴포넌트
하이라이팅된 HTML을 감싸는 래퍼 컴포넌트다. 상단 바에 언어명과 복사 버튼을 붙였다.
import { useState } from "react";
import { Check, Copy } from "lucide-react";
export default function CodeBlock({ language, children, html }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div>
<div>
<span>{language || "text"}</span>
<button onClick={handleCopy}>
{copied ? <><Check size={16} /> Copied!</> : <><Copy size={16} /> Copy</>}
</button>
</div>
<div dangerouslySetInnerHTML={{ __html: html || "" }} />
</div>
);
}
children에는 원본 코드 문자열이, html에는 빌드 타임에 만들어둔 하이라이팅 HTML이 들어있다. 복사 버튼을 누르면 하이라이팅 HTML이 아니라 원본 코드(children)를 클립보드에 넣는다.
react-markdown 연동
react-markdown의 components prop에서 code 요소를 커스터마이징한다. 마크다운 파서가 발견한 코드 블록마다 이 컴포넌트가 호출되는데, 여기서 미리 만들어둔 codeBlocks 맵에서 해당 블록의 HTML을 꺼내 CodeBlock에 넘긴다.
components={{
pre: ({ children }) => <>{children}</>,
code: ({ inline, className, children }) => {
const match = /language-(\w+)/.exec(className || "");
if (!inline && match) {
const code = String(children).replace(/\n$/, "");
const language = match[1];
// Mermaid는 클라이언트에서 렌더링
if (language === "mermaid") {
return <MermaidBlock chart={code} />;
}
const blockKey = `block-${codeBlockIndex++}`;
const html = codeBlocks[blockKey] || "";
return (
<CodeBlock language={language} html={html}>
{code}
</CodeBlock>
);
}
return <code className={className}>{children}</code>;
},
}}
pre 태그는 그냥 children을 반환한다. react-markdown이 pre > code 구조로 렌더링하는데, pre를 없애고 code만 커스터마이징하면 된다. 인라인 코드는 기본 스타일을 유지하고, 코드 블록만 CodeBlock 컴포넌트로 대체한다.
Mermaid는 빌드 타임에 스킵했기 때문에 여기서도 별도 분기가 필요하다. extractAndHighlightCodeBlocks에서 스킵한 것과 이 분기가 한 쌍으로 동작한다. 한쪽만 있으면 Mermaid 블록이 엉뚱하게 렌더링된다.
정리
- Shiki는 TextMate 문법 기반으로 VS Code와 동일한 토크나이저를 사용해 복잡한 TypeScript 문법도 정확하게 하이라이팅한다
- WASM 번들이 무거워서 런타임에 돌리기 부담스럽다.
extractAndHighlightCodeBlocks함수로 빌드 타임에 미리 처리하고getStaticProps에서 호출한다 - 언어명 정규화, Mermaid 스킵, try/catch fallback을 한 함수 안에 묶어서 빌드가 깨지지 않도록 방어한다
- 다크모드는 라이트/다크 HTML을 둘 다 생성해두고 Tailwind
dark:접두사로 토글한다 react-markdown의code컴포넌트를 커스터마이징해 미리 만들어둔 HTML을dangerouslySetInnerHTML로 주입한다. Mermaid는 별도 분기로 클라이언트에서 렌더링한다