블로그 개발일지 #6 — rehype-pretty-code로 코드 블록 업그레이드
결론부터
블로그의 코드 블록을 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-code | Shiki 기반, 빌드 타임 처리, SSR 친화적 | 복사 버튼은 직접 구현 필요 |
| Prism.js | 플러그인 많음 | 런타임 처리, 번들 크기 |
| highlight.js | 언어 자동 감지 | 테마 제한적, 성능 |
rehype-pretty-code는 Shiki를 내부적으로 사용하면서도 라인 하이라이트, diff 표시 같은 고급 기능을 메타데이터 문법으로 제공한다. 빌드 타임에 모든 처리를 끝내므로 클라이언트 번들에 부담이 없다.
구현 과정
1. 패키지 설치 및 Astro 설정
pnpm add rehype-pretty-codeastro.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'
};
```실제 렌더링:
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');
}
```실제 렌더링:
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/transformers의 transformerNotationDiff()는 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 — 이 블로그의 소스 코드