Shiki로 코드 하이라이팅하기

개발 블로그나 기술 문서를 작성할 때 코드를 예쁘게 보여주는 것은 생각보다 중요한 문제입니다. 읽기 좋은 코드 하이라이팅은 독자가 내용을 빠르게 파악하는 데 큰 도움이 되기 때문이죠.

이번 포스팅에서는 VS Code와 동일한 문법 엔진을 사용해서 아름답고 정확한 코드 하이라이팅을 제공하는 Shiki에 대해서 알아보겠습니다.

Shiki란?

Shiki(式, 일본어로 “스타일”이라는 뜻)는 코드를 구문 강조(syntax highlighting)해주는 JavaScript 라이브러리입니다. VS Code에서 사용하는 것과 동일한 TextMate 문법과 테마를 사용하기 때문에, VS Code에서 보던 것과 똑같은 모습으로 코드를 하이라이팅할 수 있습니다.

기존에 많이 사용되던 PrismJShighlight.js와 비교했을 때 Shiki는 몇 가지 특징적인 장점이 있습니다. 우선, 정규 표현식 기반이 아닌 TextMate 문법을 사용하기 때문에 훨씬 정교하고 정확한 하이라이팅을 제공합니다. 또한 사전 렌더링(pre-rendering)을 지원하여 런타임에 JavaScript가 필요 없이 순수 HTML만으로 하이라이팅된 코드를 보여줄 수 있습니다. 그리고 Node.js, 브라우저, Cloudflare Workers 등 다양한 JavaScript 런타임에서 동작합니다.

Shiki 설치

Shiki는 npm을 통해 설치할 수 있습니다. 빌드 시에만 필요하므로 개발 의존성으로 설치합니다.

$ npm install -D shiki

Bun을 사용하는 프로젝트라면 다음과 같이 설치합니다.

$ bun add -D shiki

codeToHtml로 간편하게 시작하기

Shiki를 사용하는 가장 간단한 방법은 codeToHtml 함수를 사용하는 것입니다. 이 함수는 코드 문자열을 받아서 하이라이팅된 HTML 문자열을 반환합니다.

import { codeToHtml } from "shiki";

const code = `const greeting = "Hello, Shiki!";
console.log(greeting);`;

const html = await codeToHtml(code, {
  lang: "javascript",
  theme: "github-dark",
});

console.log(html);

위 코드를 실행하면 다음과 같은 HTML이 출력됩니다.

<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0">
  <code>
    <span class="line">
      <span style="color:#F97583">const</span>
      <span style="color:#79B8FF"> greeting</span>
      <span style="color:#F97583"> =</span>
      <span style="color:#9ECBFF"> "Hello, Shiki!"</span>
      <span style="color:#E1E4E8">;</span>
    </span>
    <span class="line">
      <span style="color:#E1E4E8">console.</span>
      <span style="color:#B392F0">log</span>
      <span style="color:#E1E4E8">(greeting);</span>
    </span>
  </code>
</pre>

보시다시피 각 토큰에 인라인 스타일이 적용되어 있어서, 별도의 CSS 없이도 바로 사용할 수 있습니다. codeToHtml 함수는 내부적으로 필요한 언어와 테마를 자동으로 로드하고 캐싱하기 때문에, 빠르게 프로토타이핑하거나 간단한 사용 사례에 적합합니다.

createHighlighter로 고성능 하이라이팅

codeToHtml은 편리하지만, 호출할 때마다 언어와 테마를 동적으로 로드합니다. 더 나은 성능이 필요하다면 createHighlighter를 사용해서 하이라이터 인스턴스를 미리 생성해두는 것이 좋습니다.

import { createHighlighter } from "shiki";

// 하이라이터 인스턴스 생성 (비동기)
const highlighter = await createHighlighter({
  themes: ["github-dark", "github-light"],
  langs: ["javascript", "typescript", "python"],
});

// 이후에는 동기적으로 하이라이팅 가능
const html = highlighter.codeToHtml('console.log("Hello")', {
  lang: "javascript",
  theme: "github-dark",
});

createHighlighter는 비동기 함수로, 초기화 시에 지정한 테마와 언어를 미리 로드합니다. 이후에는 highlighter.codeToHtml을 동기적으로 호출할 수 있어서 반복적인 하이라이팅 작업에 효율적입니다.

하이라이터 인스턴스는 가능하면 애플리케이션 전체에서 재사용하는 것이 좋습니다. 매번 새로운 인스턴스를 생성하면 불필요한 오버헤드가 발생하기 때문이죠.

라이트/다크 테마 지원

요즘 대부분의 웹사이트는 라이트 모드와 다크 모드를 지원합니다. Shiki도 듀얼 테마를 손쉽게 지원하는데요, 이는 Shiki의 매우 강력한 기능 중 하나입니다.

import { codeToHtml } from "shiki";

const html = await codeToHtml('console.log("Hello")', {
  lang: "javascript",
  themes: {
    light: "github-light",
    dark: "github-dark",
  },
});

위와 같이 theme 대신 themes 옵션을 사용하면, 두 테마의 스타일이 모두 포함된 HTML이 생성됩니다. 그런 다음 CSS를 통해 현재 모드에 맞는 스타일만 보여주면 됩니다.

@media (prefers-color-scheme: dark) {
  .shiki,
  .shiki span {
    color: var(--shiki-dark) !important;
    background-color: var(--shiki-dark-bg) !important;
  }
}

또는 클래스 기반으로 테마를 전환할 수도 있습니다.

html.dark .shiki,
html.dark .shiki span {
  color: var(--shiki-dark) !important;
  background-color: var(--shiki-dark-bg) !important;
}

이렇게 하면 사용자의 시스템 설정이나 수동 토글에 따라 코드 블록의 테마가 자동으로 전환됩니다.

다양한 테마와 언어

Shiki는 정말 다양한 테마와 언어를 지원합니다. 공식 테마 목록을 보면 Dracula, One Dark Pro, Solarized, Catppuccin 등 인기 있는 테마들이 모두 포함되어 있습니다. 지원 언어 목록도 거의 모든 주요 프로그래밍 언어를 포함하고 있죠.

필요한 테마나 언어가 기본 번들에 없다면, VS Code 마켓플레이스에서 가져온 TextMate 문법 파일을 직접 로드할 수도 있습니다.

const highlighter = await createHighlighter({
  themes: [],
  langs: [],
});

// 커스텀 테마 로드
await highlighter.loadTheme(myCustomTheme);

// 커스텀 언어 로드
await highlighter.loadLanguage(myCustomLanguage);

Transformers로 기능 확장

Shiki의 Transformers API를 사용하면 하이라이팅된 HTML에 다양한 기능을 추가할 수 있습니다. @shikijs/transformers 패키지에는 자주 사용되는 트랜스포머들이 이미 준비되어 있습니다.

$ npm install -D @shikijs/transformers

트랜스포머를 사용하려면 transformers 옵션에 배열로 전달하면 됩니다.

import { codeToHtml } from "shiki";
import {
  transformerNotationDiff,
  transformerNotationHighlight,
  transformerNotationWordHighlight,
  transformerNotationFocus,
  transformerNotationErrorLevel,
} from "@shikijs/transformers";

const html = await codeToHtml(code, {
  lang: "javascript",
  theme: "github-dark",
  transformers: [
    transformerNotationDiff(),
    transformerNotationHighlight(),
    transformerNotationWordHighlight(),
    transformerNotationFocus(),
    transformerNotationErrorLevel(),
  ],
});

그럼 각 트랜스포머가 어떤 기능을 제공하는지 하나씩 살펴보겠습니다.

transformerNotationDiff를 사용하면 코드의 변경 사항을 diff 스타일로 표시할 수 있습니다. // [!code ++]는 추가된 라인, // [!code --]는 삭제된 라인으로 표시됩니다.

console.log("hello"); // [!code --]
console.log("world"); // [!code ++]

transformerNotationHighlight는 특정 라인을 강조할 때 사용합니다. 주석에 // [!code highlight]를 추가하면 해당 라인이 하이라이트됩니다. 여러 줄을 한 번에 강조하려면 // [!code highlight:3]처럼 숫자를 지정하면 됩니다.

const a = 1;
const b = 2; 
const c = 3;

transformerNotationWordHighlight는 특정 단어만 강조하고 싶을 때 유용합니다. // [!code word:Hello]처럼 주석을 달면 코드 내의 Hello라는 단어가 모두 강조됩니다.

// [!code word:greeting]
const greeting = "Hello";
console.log(greeting); // greeting 단어가 강조됨

transformerNotationFocus는 특정 라인에 포커스 효과를 줄 때 사용합니다. 포커스된 라인 외의 코드는 흐리게 표시되어 독자의 시선을 집중시킬 수 있습니다.

console.log("배경");
console.log("포커스!"); // [!code focus]
console.log("배경");

transformerNotationErrorLevel을 사용하면 에러나 경고를 표시할 수 있습니다. // [!code error]는 에러 라인, // [!code warning]는 경고 라인으로 스타일링됩니다.

console.log("정상");
console.error("에러!"); // [!code error]
console.warn("경고!"); // [!code warning]

이러한 주석들은 최종 HTML 출력에서 자동으로 제거되므로 사용자에게는 깔끔한 코드만 보입니다. 트랜스포머가 추가하는 CSS 클래스에 맞는 스타일을 정의해주면 원하는 시각적 효과를 적용할 수 있습니다.

파일명 표시

코드 블록에 파일명을 표시하고 싶을 때가 있습니다. 공식 @shikijs/transformers에는 이 기능이 포함되어 있지 않지만, 커뮤니티 패키지를 사용하거나 직접 커스텀 트랜스포머를 작성하면 됩니다.

먼저 커뮤니티 패키지인 @rudeigerc/shiki-transformer-title를 사용하는 방법입니다.

$ npm install -D @rudeigerc/shiki-transformer-title

설치 후 다음과 같이 사용합니다.

import { codeToHtml } from "shiki";
import { transformerTitle } from "@rudeigerc/shiki-transformer-title";

const html = await codeToHtml(code, {
  lang: "javascript",
  theme: "github-dark",
  meta: { __raw: 'title="app.js"' },
  transformers: [transformerTitle()],
});

이렇게 하면 코드 블록 위에 app.js라는 파일명이 표시됩니다. CSS로 .shiki-title 클래스에 적절한 스타일을 적용하면 원하는 모습으로 꾸밀 수 있습니다.

외부 패키지 없이 직접 구현하고 싶다면 커스텀 트랜스포머를 작성할 수도 있습니다.

import { codeToHtml } from "shiki";

const html = await codeToHtml(code, {
  lang: "javascript",
  theme: "github-dark",
  meta: { __raw: 'title="app.js"' },
  transformers: [
    {
      name: "add-filename",
      pre(node) {
        // meta에서 title 추출
        const match = this.options.meta?.__raw?.match(/title="([^"]+)"/);
        if (match) {
          // 파일명을 담은 div 요소 생성
          const filenameNode = {
            type: "element",
            tagName: "div",
            properties: { class: "code-filename" },
            children: [{ type: "text", value: match[1] }],
          };
          // pre 요소 앞에 삽입
          node.children.unshift(filenameNode);
        }
      },
    },
  ],
});

위 코드는 meta 옵션에서 title을 파싱해서 코드 블록 상단에 파일명을 추가합니다. 이 방식은 외부 의존성 없이 프로젝트의 요구 사항에 맞게 자유롭게 커스터마이징할 수 있다는 장점이 있습니다.

프레임워크 통합

Shiki는 다양한 프레임워크 및 도구와 쉽게 통합할 수 있습니다. 공식적으로 지원되는 통합 패키지들이 제공되며 그 중 몇 가지를 소개하자면, markdown-it 플러그인을 사용하면 Markdown 파서와 함께 사용할 수 있고, Rehype 플러그인을 사용하면 unified 생태계와 함께 사용할 수 있습니다. 또한 VitePressAstro는 기본 신텍스 하이라이터로 Shiki를 사용하고 있습니다.

마치며

이번 포스팅에서는 Shiki의 기본적인 사용법부터 고급 기능까지 살펴보았습니다. VS Code와 동일한 하이라이팅 품질을 웹에서도 누릴 수 있다는 점이 Shiki의 가장 큰 매력인 것 같습니다.

특히 정적 사이트 생성기나 문서 도구를 사용한다면, 빌드 타임에 하이라이팅을 수행해서 클라이언트에는 JavaScript 없이 순수 HTML만 전달할 수 있다는 점도 큰 장점입니다. 코드 하이라이팅 라이브러리를 찾고 계신다면 Shiki를 한번 사용해보시길 추천드립니다! 🎨

This work is licensed under CC BY 4.0 CC BY

Discord