재하의 개발 블로그
회고 21 분 소요

블로그 개발일지 #6 — rehype-pretty-code로 코드 블록 업그레이드

astro rehype-pretty-code shiki code-highlighting ux

결론부터

블로그의 코드 블록을 Astro 기본 Shiki 설정에서 rehype-pretty-code로 전환했다. 이제 다음 기능들을 모두 지원한다.

  • ✅ 라인 번호 (자동 표시)
  • ✅ 라인 하이라이트 ({1,3-5} 문법)
  • ✅ Diff 표시 ([!code ++], [!code --])
  • ✅ 파일명 헤더 (title="config.ts")
  • ✅ 언어 라벨 (우측 상단 자동)
  • ✅ 복사 버튼 (호버 시 표시)
  • ✅ 다크/라이트 테마 자동 전환
  • ✅ 인라인 코드 분리 (코드 블록과 다르게 렌더링)

기존 기본 설정은 syntax highlighting만 있었다. 이제 기술 블로그로서 필요한 모든 코드 블록 기능을 갖췄다.

왜 바꿨나

기존 방식의 한계

Astro는 markdown.shikiConfig로 간단한 syntax highlighting을 제공한다. 하지만 다음이 불가능했다.

// astro.config.ts — 기존 설정
markdown: {
  shikiConfig: {
    themes: {
      light: "github-light",
      dark: "github-dark",
    },
  },
}

이 설정으로는 색상만 칠해진 코드 블록이 나온다. 라인 번호도, 하이라이트도, 복사 버튼도 없다. 기술 블로그에서 코드를 설명할 때 "3번 라인 보세요"라고 말하려면 라인 번호가 있어야 하고, 중요한 부분을 강조하려면 하이라이트가 필요하다.

rehype-pretty-code를 선택한 이유

다른 선택지도 있었다.

라이브러리장점단점
rehype-pretty-codeShiki 기반, 빌드 타임 처리, SSR 친화적복사 버튼은 직접 구현 필요
Prism.js플러그인 많음런타임 처리, 번들 크기
highlight.js언어 자동 감지테마 제한적, 성능

rehype-pretty-code는 Shiki를 내부적으로 사용하면서도 라인 하이라이트, diff 표시 같은 고급 기능을 메타데이터 문법으로 제공한다. 빌드 타임에 모든 처리를 끝내므로 클라이언트 번들에 부담이 없다.

구현 과정

1. 패키지 설치 및 Astro 설정

pnpm add rehype-pretty-code

astro.config.ts에서 shikiConfig를 제거하고 rehype-pretty-code를 rehype 플러그인으로 추가했다.

import rehypePrettyCode from "rehype-pretty-code";
 
export default defineConfig({
  markdown: {
    remarkPlugins: [remarkMermaid, remarkCallout],
    rehypePlugins: [
      rehypeRaw,
      [
        rehypePrettyCode,
        {
          theme: {
            light: "github-light",
            dark: "github-dark",
          },
          keepBackground: false,
          defaultLang: "plaintext",
          bypassInlineCode: true,  // 인라인 코드는 처리하지 않음
          onVisitLine(node: any) {
            // 빈 라인이 collapse되지 않도록 처리
            if (node.children.length === 0) {
              node.children = [{ type: "text", value: " " }];
            }
          },
          onVisitHighlightedLine(node: any) {
            node.properties.className = node.properties.className || [];
            node.properties.className.push("highlighted");
          },
          onVisitHighlightedChars(node: any) {
            node.properties.className = ["highlighted-chars"];
          },
        },
      ],
    ],
  },
});

커스텀 옵션 설명

  • bypassInlineCode: 인라인 코드(code)를 처리하지 않고 건너뛰도록 설정. 이게 없으면 인라인 코드도 코드 블록처럼 렌더링됨
  • onVisitLine: 빈 라인이 화면에서 사라지는 걸 방지 (공백 문자 삽입)
  • onVisitHighlightedLine: 하이라이트된 라인에 CSS 클래스 추가
  • onVisitHighlightedChars: 특정 단어/구문 하이라이트에 클래스 추가

2. CSS 스타일링

rehype-pretty-code가 생성하는 HTML 구조는 다음과 같다.

<figure data-rehype-pretty-code-figure>
  <figcaption data-rehype-pretty-code-title>
    app.js
  </figcaption>
  <pre data-language="js">
    <code>
      <span data-line>...</span>
    </code>
  </pre>
</figure>

CSS 변수를 기반으로 스타일을 정의했다.

/* 코드 블록 컨테이너 */
.mdx-content figure[data-rehype-pretty-code-figure] {
  position: relative;
  margin: 1.5em 0;
  overflow: hidden;
  border-radius: 0.5rem;
  border: 1px solid var(--border);
  background-color: var(--muted);
}
 
/* 라인 번호 */
.mdx-content code[data-line-numbers] > [data-line]::before {
  counter-increment: line;
  content: counter(line);
  display: inline-block;
  width: 1rem;
  margin-right: 1.5rem;
  margin-left: 1rem;
  text-align: right;
  color: var(--muted-foreground);
  opacity: 0.5;
}
 
/* 하이라이트 라인 */
.mdx-content [data-line].highlighted {
  background-color: oklch(var(--accent) / 0.1);
  border-left: 3px solid oklch(var(--primary) / 0.7);
}
 
/* Diff 라인 — 추가 */
.mdx-content [data-line][data-highlighted-chars*="add"] {
  background-color: oklch(142 52% 24% / 0.15);
  border-left: 3px solid oklch(142 52% 24%);
}
 
/* Diff 라인 — 삭제 */
.mdx-content [data-line][data-highlighted-chars*="remove"] {
  background-color: oklch(348 83% 47% / 0.15);
  border-left: 3px solid oklch(348 83% 47%);
}

다크 모드는 .dark 클래스 스코프에서 색상 변수를 오버라이드하는 방식으로 처리했다.

3. 복사 버튼 구현

rehype-pretty-code는 복사 버튼을 제공하지 않으므로 클라이언트 스크립트로 직접 구현했다.

// src/scripts/code-block.ts
const COPY_ICON = `<svg xmlns="...">...</svg>`;
const CHECK_ICON = `<svg xmlns="...">...</svg>`;
 
function createCopyButton(): HTMLButtonElement {
  const button = document.createElement("button");
  button.className = "copy-button";
  button.setAttribute("aria-label", "Copy code");
  button.innerHTML = COPY_ICON;
  return button;
}
 
function getCodeText(figure: HTMLElement): string {
  const codeElement = figure.querySelector("code");
  if (!codeElement) return "";
 
  // 라인 번호 제외하고 텍스트만 추출
  const lines = codeElement.querySelectorAll("[data-line]");
  return Array.from(lines)
    .map((line) => line.textContent || "")
    .join("\n");
}
 
async function copyCode(button: HTMLButtonElement, figure: HTMLElement) {
  const code = getCodeText(figure);
 
  try {
    await navigator.clipboard.writeText(code);
 
    // 체크 아이콘으로 변경
    button.innerHTML = CHECK_ICON;
    button.classList.add("copied");
    button.setAttribute("aria-label", "Copied!");
 
    // 2초 후 복구
    setTimeout(() => {
      button.innerHTML = COPY_ICON;
      button.classList.remove("copied");
      button.setAttribute("aria-label", "Copy code");
    }, 2000);
  } catch (err) {
    console.error("Failed to copy code:", err);
  }
}
 
// 모든 코드 블록에 버튼 추가
function initCodeBlocks() {
  const figures = document.querySelectorAll("[data-rehype-pretty-code-figure]");
 
  figures.forEach((figure) => {
    if (figure.querySelector(".copy-button")) return;
 
    const button = createCopyButton();
    button.addEventListener("click", () => copyCode(button, figure));
    figure.insertBefore(button, figure.firstChild);
  });
}
 
// 페이지 로드 시 실행
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", initCodeBlocks);
} else {
  initCodeBlocks();
}
 
// Astro View Transitions 지원
document.addEventListener("astro:page-load", initCodeBlocks);

Astro 레이아웃 파일에서 스크립트를 import했다.

<script src="../scripts/code-block.ts"></script>

4. 언어 라벨

CSS ::before pseudo-element로 언어 라벨을 자동 생성했다.

.mdx-content figure[data-rehype-pretty-code-figure] > pre::before {
  content: attr(data-language);
  position: absolute;
  top: 0.5rem;
  right: 3.5rem; /* 복사 버튼 옆 */
  font-size: 0.75rem;
  text-transform: uppercase;
  color: var(--muted-foreground);
  opacity: 0.7;
}
 
/* 파일명이 있으면 언어 라벨 숨김 */
.mdx-content figcaption[data-rehype-pretty-code-title] + pre::before {
  display: none;
}

5. 기존 컴포넌트 삭제

src/components/mdx/CodeBlock.tsx는 MDX 전용이었고 더 이상 사용하지 않으므로 삭제했다.

지원하는 문법

기본 코드 블록

```js
console.log('Hello');
```

실제 렌더링:

console.log('Hello');

라인 하이라이트

```js {1,3-5}
const a = 1;
const b = 2;
const c = 3;
const d = 4;
const e = 5;
```

실제 렌더링:

const a = 1;
const b = 2;
const c = 3;
const d = 4;
const e = 5;

파일명 표시

```typescript title="config.ts"
export default { 
  name: 'app',
  version: '1.0.0'
};
```

실제 렌더링:

config.ts
export default { 
  name: 'app',
  version: '1.0.0'
};

Diff 표시

```js
const old = 1; // [!code --]
const new = 2; // [!code ++]
const keep = 3;
```

실제 렌더링:

const old = 1; 
const new = 2; 
const keep = 3;

복합 사용

```typescript title="app.ts" {2-4}
function main() {
  console.log('start');
  doSomething();
  console.log('end');
}
```

실제 렌더링:

app.ts
function main() {
  console.log('start');
  doSomething();
  console.log('end');
}

트러블슈팅

1. 인라인 코드가 코드 블록처럼 렌더링됨

처음에는 markdown.shikiConfig를 rehype-pretty-code로 바꾸기만 했다. 그런데 인라인 코드(code)가 코드 블록처럼 렌더링되는 문제가 발생했다.

인라인 코드 `{1,3-5}`는 라인 번호와 복사 버튼이 있으면 안 된다.

위 텍스트에서 `{1,3-5}`가 작은 코드 블록처럼 보이고, 라인 번호와 data-line 속성이 추가되었다.

해결 방법

rehype-pretty-code의 bypassInlineCode 옵션을 true로 설정했다.

rehypePrettyCode,
{
  theme: { /* ... */ },
  keepBackground: false,
  defaultLang: "plaintext",
  bypassInlineCode: true,  // 인라인 코드 처리 건너뛰기
  onVisitLine(node: any) { /* ... */ },
}

이 옵션은 <pre><code> 구조가 아닌 단독 <code> 요소를 무시하도록 한다. 이제 인라인 코드는 일반 <code> 태그로만 렌더링되고, 코드 블록은 모든 기능이 정상 작동한다.

2. TypeScript 타입 에러

onVisitLine, onVisitHighlightedLine, onVisitHighlightedChars의 파라미터에 타입이 없어서 빌드 실패했다.

// Before
onVisitLine(node) { ... }
 
// After
onVisitLine(node: any) { ... }

rehype-pretty-code의 타입 정의가 완벽하지 않아 any로 우회했다.

3. Mermaid 블록 충돌 방지

remarkMermaid 플러그인이 mermaid 코드 블록을 먼저 처리하므로 rehype-pretty-code와 충돌하지 않는다. remarkMermaid가 <pre class="mermaid">로 변환한 후에는 data-language 속성이 없어서 rehype-pretty-code가 무시한다.

4. 기존 컴포넌트 미사용 확인

CodeBlock.tsx가 실제로 어디에서도 import되지 않았지만, 혹시 몰라 전체 코드베이스를 검색했다.

grep -r "CodeBlock" src/

import 구문이 없음을 확인하고 삭제했다.

5. Diff CSS 셀렉터 불일치

증상: Diff 마커([!code ++], [!code --])를 사용했지만 빨간색/초록색 하이라이팅이 나타나지 않음.

원인: @shikijs/transformerstransformerNotationDiff()class를 추가하지만, CSS 셀렉터가 data attribute를 타겟팅하고 있었음.

  • HTML 실제 출력: <span class="line diff add">
  • CSS 잘못된 셀렉터: .mdx-content [data-line][data-highlighted-chars*="add"]

해결책: CSS 셀렉터를 class 기반으로 변경

/* globals.css */
.mdx-content [data-line].diff.add {
  background-color: rgba(34, 197, 94, 0.15);
  border-left: 3px solid rgb(34, 197, 94);
}
 
.mdx-content [data-line].diff.remove {
  background-color: rgba(239, 68, 68, 0.15);
  border-left: 3px solid rgb(239, 68, 68);
}

6. PostCSS가 CSS 규칙을 제거함

증상: CSS 파일에는 규칙이 있지만 브라우저에서 보면 빈 규칙으로 나타남 (예: .diff.add { }).

원인: oklch() 색상 포맷의 알파 채널 문법(oklch(142 52% 24% / 0.15))을 PostCSS가 파싱하지 못하고 전체 속성을 제거함.

해결책: 표준 rgba()/rgb() 포맷으로 변경

/* 잘못된 문법 (PostCSS가 제거) */
background-color: oklch(142 52% 24% / 0.15);
 
/* 올바른 문법 */
background-color: rgba(34, 197, 94, 0.15);

7. 문법 강조가 보이지 않음

증상: TypeScript, JavaScript 등 모든 코드 블록이 단색으로 표시되고 키워드, 문자열 등이 구분되지 않음.

원인: Shiki가 인라인 스타일로 CSS 변수를 생성(style="--shiki-light:#6A737D")하지만, 이 변수를 실제 color 속성에 적용하는 CSS가 없었음.

해결책: Shiki CSS 변수를 적용하는 규칙 추가

/* globals.css */
.mdx-content code span {
  color: var(--shiki-light);
}
 
.dark .mdx-content code span {
  color: var(--shiki-dark);
}

검증 방법: Playwright 브라우저 자동화로 실제 렌더링된 HTML과 computed style을 확인했다.

MCP 가이드라인 업데이트

블로그 MCP 서버의 글쓰기 가이드라인에 코드 블록 문법을 추가했다.

// mcp-server/src/index.ts
const WRITING_GUIDELINES = `...
 
### 코드 블록 (고급 기능 지원)
블로그는 rehype-pretty-code를 사용하여 다양한 코드 블록 기능을 지원한다:
 
- 라인 하이라이트: {1,3-5}
- 파일명 표시: title="config.ts"
- Diff 표시: [!code ++] 또는 [!code --]
- 복합 사용: title="app.ts" {2-4}
 
...`;

이제 Claude Code에서 MCP로 블로그 글을 작성할 때 자동으로 이 문법들을 사용할 수 있다.

성과 및 회고

잘한 점

  • 빌드 타임 처리 — 클라이언트 번들에 부담 없음
  • CSS 변수 기반 — 다크 모드 전환이 매끄러움
  • 접근성 고려 — 복사 버튼에 aria-label 추가
  • Astro View Transitions 지원 — 페이지 전환 시에도 버튼 정상 작동
  • 인라인 코드 분리bypassInlineCode 옵션으로 깔끔하게 해결
  • 체계적인 트러블슈팅 — Playwright로 실제 렌더링 결과를 검증하며 문제 해결

아쉬운 점

  • 복사 버튼 스타일 조정 — 호버 시에만 보이도록 했지만, 터치 디바이스에서는 호버가 없어서 항상 보여야 할지 고민 중
  • 라인 하이라이트 범위 제한 — rehype-pretty-code는 단어/구문 하이라이트도 지원하는데 아직 활용하지 못했음

배운 것

rehype 플러그인의 AST 훅

onVisitLine 같은 훅으로 AST 노드를 직접 조작할 수 있다는 게 강력했다. HTML 생성 전에 노드 속성을 추가하거나 자식을 조작하면, 원하는 대로 출력을 제어할 수 있다.

CSS ::before의 활용

언어 라벨을 DOM에 추가하지 않고 CSS만으로 구현했다. content: attr(data-language)로 HTML 속성을 읽어서 표시하는 방식이 깔끔했다.

Clipboard API의 비동기 특성

navigator.clipboard.writeText()가 Promise를 반환하므로 async/await로 처리해야 한다. 복사 실패 시 에러 처리도 중요하다.

bypassInlineCode 옵션의 중요성

rehype-pretty-code는 기본적으로 모든 <code> 요소를 처리한다. 인라인 코드와 코드 블록을 구분하려면 bypassInlineCode: true 옵션이 필수다.

@shikijs/transformers의 동작 원리

transformerNotationDiff()transformerNotationHighlight()는 HTML 구조에 class를 추가하지 data attribute를 추가하지 않는다. CSS 셀렉터를 작성할 때 실제 생성되는 HTML을 확인하는 게 중요하다.

PostCSS와 최신 CSS 문법의 호환성

oklch() 같은 최신 CSS 색상 포맷은 PostCSS가 파싱하지 못할 수 있다. 특히 알파 채널 문법(/ 0.15)은 호환성 문제가 있으므로 표준 rgba() 포맷을 사용하는 게 안전하다.

Shiki CSS 변수 시스템

Shiki는 --shiki-light--shiki-dark CSS 변수를 인라인 스타일로 생성하지만, 이를 실제 color 속성에 적용하는 CSS는 별도로 작성해야 한다.

다음 계획

  • 단어 하이라이트: [!code highlight] 문법으로 특정 단어 강조
  • 라인 주석: [!code note: 여기 중요] 같은 주석 표시
  • 모바일 UX 개선: 터치 디바이스에서 복사 버튼 표시 방식 개선

블로그 개발일지 #5 — 정규식 앵커와 멀티라인 텍스트 — GFM Alert 렌더링 버그 수정기

idjaeha/my-blog — 이 블로그의 소스 코드