Open Source

Markdown의 Backslash Escape 변환 오류 수정

2026년 3월 6일

Lexical의 Markdown 변환 과정에서 백슬래시(\) 이스케이프 문자가 텍스트에 남는 문제를 해결한 과정을 공유합니다.

문제 상황

Lexical에서 Markdown을 텍스트나 노드로 변환할 때, 특정 엣지 케이스에서 백슬래시가 의도치 않게 노출되거나 파싱 로직이 스펙과 다르게 동작하는 현상이 발견되었습니다.

1. 백슬래시 이스케이프 문자 노출

Markdown 스펙에 따르면, 구문 기호 앞에 붙은 백슬래시는 해당 기호를 일반 문자로 취급하도록 하며 변환 결과에서는 사라져야 합니다. 하지만 다음과 같은 경우 백슬래시가 제거되지 않고 텍스트 노드에 그대로 포함되었습니다.

  • 입력: - 1\. foo
  • 기대 결과: - 1. foo (숫자 뒤의 점이 리스트로 인식되지 않도록 이스케이프됨)
  • 실제 동작: - 1\. foo (백슬래시가 그대로 남음)

2. 중첩 리스트 파싱 오류

- 1. foo와 같은 입력에서 언오더드 리스트(Unordered List) 내부에 오더드 리스트(Ordered List) 형식이 공존해야 하는 상황임에도 불구하고, 단순 언오더드 리스트로만 처리되는 등 파싱 구조의 모호함이 있었습니다.


원인 분석

정규표현식의 범위 제한

기존의 백슬래시 제거 로직은 일부 강조 기호(*, _, `, ~, \)에 대해서만 처리하도록 한정되어 있었습니다.

// (수정 전) 일부 기호만 대응하던 로직
const escapedText = textContent
  .replace(/\\([*_`~\\])/g, "$1")
  .replace(/&#(\d+);/g, (_, codePoint) => {
    return String.fromCodePoint(codePoint);
  });
textNode.setTextContent(escapedText);

CommonMark 스펙(Backslash escapes)에 따르면, 모든 ASCII 문장 부호(Punctuation characters)는 백슬래시로 이스케이프될 수 있어야 합니다. 기존 로직은 이 범위를 모두 커버하지 못해 \.와 같은 경우 백슬래시가 남게 된 것입니다.


해결 방법

1. 백슬래시 이스케이프 유틸리티 함수 도입

모든 ASCII 문장 부호를 처리할 수 있도록 정규표현식을 확장하고, 이를 재사용 가능한 유틸리티 함수로 분리하여 텍스트 및 링크 URL/Title 처리 시 적용했습니다.

// (수정 후) 모든 ASCII 문장 부호를 처리하는 유틸리티 함수
export function decodeAndShortenText(text: string): string {
  return text
    .replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])/g, "$1")
    .replace(/&#(\d+);/g, (_, codePoint) =>
      String.fromCodePoint(Number(codePoint)),
    );
}

2. 하드 라인 브레이크 로직 개선

문장 끝의 백슬래시가 하드 라인 브레이크로 인식될 때, 텍스트 노드 마지막의 백슬래시를 명확히 제거하도록 로직을 추가했습니다.

// 하드 라인 브레이크(Hard Line Breaks) 처리 시 백슬래시 제거
const lastChild = targetNode.getLastChild();
if ($isTextNode(lastChild)) {
  const lastText = lastChild.getTextContent();
  if (lastText.endsWith("\\")) {
    lastChild.setTextContent(lastText.slice(0, -1));
  }
}

playground