정규식 앵커와 멀티라인 텍스트 — GFM Alert 렌더링 버그 수정기
결론부터
블로그의 GFM Alert([!TIP], [!NOTE] 등)가 스타일링된 콜아웃 박스 대신 평범한 인용문으로 렌더링되던 버그를 수정했다. 원인은 remarkCallout 플러그인의 정규식 패턴이 ^와 $ 앵커를 사용해 멀티라인 텍스트를 처리하지 못했기 때문이다.
// Before: 멀티라인 텍스트 매칭 실패
const ALERT_PATTERN = /^\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]\s*(.*)?$/i;
// After: s 플래그로 dotAll 모드 활성화
const ALERT_PATTERN = /^\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]\s*(.*)$/is;s 플래그를 추가하면 . 메타문자가 개행 문자(\n)까지 매칭하게 되어 멀티라인 콘텐츠를 정상적으로 캡처할 수 있다.
문제 발견
블로그 글을 작성하고 나니 [!TIP] 같은 GFM Alert가 제대로 렌더링되지 않았다. 브라우저에서 확인하니 스타일링된 콜아웃 박스가 아니라 평범한 <blockquote>로 나타났다.
흥미로운 점은 hello-world 포스트의 Alert는 정상적으로 렌더링되는데, 새로 작성한 building-blog-with-ai-4 포스트의 Alert는 렌더링되지 않았다는 것이다.
첫 번째 가설의 함정 처음에는 "어떤 글은 되고 어떤 글은 안 된다"는 사실에서 마크다운 문법 차이나 로더 설정 문제를 의심했다. 하지만 실제 원인은 정규식 자체가 멀티라인 텍스트를 처리하지 못하는 근본적인 버그였다.
원인 분석
정규식 앵커의 동작 방식
문제의 정규식은 다음과 같았다:
const ALERT_PATTERN = /^\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]\s*(.*)?$/i;여기서 ^와 $ 앵커는 문자열의 시작과 끝을 의미한다. 하지만 remarkParse가 블록쿼트를 파싱할 때, 멀티라인 콘텐츠를 하나의 텍스트 노드로 만든다:
// remarkParse의 결과 (AST 텍스트 노드)
"[!TIP] MDX vs Markdown\nMDX는 JSX 컴포넌트를 쓸 수 있어...\n도구와 호환되지 않는다."이 경우 정규식은 다음처럼 동작한다:
^는[!TIP]의[앞에 매칭$는 문자열 맨 끝을 찾으려 함- 하지만
[!TIP] MDX vs Markdown뒤에 개행 문자와 추가 텍스트가 있음 $가 매칭에 실패하므로 전체 패턴 매칭 실패
테스트로 확인
실제로 테스트 스크립트로 확인해보니:
const text = `[!TIP] MDX vs Markdown
MDX는 JSX 컴포넌트를 쓸 수 있어 확장성이 높지만...`;
console.log(text.match(/^\[!TIP\]\s*(.*)?$/i));
// 결과: null (매칭 실패)
console.log(text.split('\n')[0].match(/^\[!TIP\]\s*(.*)?$/i));
// 결과: 성공 (첫 줄만 테스트하면 매칭됨)이로써 정규식이 멀티라인 텍스트를 처리하지 못한다는 것이 확인되었다.
해결 방법
dotAll 플래그 활용
JavaScript 정규식의 s 플래그(dotAll)를 사용하면 . 메타문자가 개행 문자까지 매칭한다:
// s 플래그 추가
const ALERT_PATTERN = /^\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]\s*(.*)$/is;변경 사항:
s플래그 추가:.이\n도 매칭하도록 함?제거:(.*)?를(.*)로 변경 (어차피*는 0개 이상이므로?는 불필요)
동작 확인
const text = `[!TIP] MDX vs Markdown
MDX는 JSX 컴포넌트를 쓸 수 있어...`;
console.log(text.match(/^\[!TIP\]\s*(.*)$/is));
// 결과: 매칭 성공, 그룹 1에 전체 콘텐츠 캡처됨수정 후 개발 서버를 재시작하니 모든 포스트의 GFM Alert가 정상적으로 렌더링되었다:
[supabase-blog] [building-blog-with-ai-4] Generated callout HTML
[supabase-blog] [hello-world] Generated callout HTML
[supabase-blog] [mcp-deep-dive-1-architecture] Generated callout HTML
...교훈
1. 정규식 앵커는 라인이 아니라 문자열 경계를 의미한다
^와 $는 각 라인의 시작/끝이 아니라 전체 문자열의 시작/끝을 매칭한다. 멀티라인 텍스트에서 각 라인을 매칭하려면:
m플래그(multiline):^와$가 각 라인의 시작/끝을 매칭하게 함s플래그(dotAll):.이 개행 문자도 매칭하게 함
이번 경우는 Alert 마커 이후의 모든 콘텐츠를 캡처해야 했으므로 s 플래그가 필요했다.
2. "어떤 건 되고 어떤 건 안 된다"는 착각일 수 있다
처음에는 hello-world는 작동하고 building-blog-with-ai-4는 작동하지 않는 것처럼 보였다. 하지만 실제로는:
- 두 포스트 모두 정규식 매칭에 실패했음
- 디버그 로그를 추가하고 나서야 둘 다 실패한다는 것을 확인
- "작동하는 것처럼 보였던" 것은 이전 빌드 캐시이거나 다른 요인이었을 가능성
가정보다 측정이 중요하다. 디버그 로그와 테스트 스크립트를 작성해 실제로 무슨 일이 일어나는지 확인해야 한다.
3. Remark 플러그인의 AST 구조 이해
Remark가 마크다운을 파싱하면 블록쿼트의 멀티라인 콘텐츠가 하나의 텍스트 노드가 된다. 이 구조를 이해하지 못하면 정규식을 잘못 작성하기 쉽다.
// 마크다운
> [!TIP] Title
> Line 1
> Line 2
// AST 구조 (단순화)
{
type: "blockquote",
children: [{
type: "paragraph",
children: [{
type: "text",
value: "[!TIP] Title\nLine 1\nLine 2" // 한 문자열에 \n 포함
}]
}]
}플러그인을 작성할 때는 먼저 AST 구조를 출력해보고, 실제 텍스트 노드의 값이 어떻게 생겼는지 확인하는 것이 중요하다.