VitePress localSearchPlugin 버그 디버깅하기
Lee Dogeon @moreal@hackers.pub
원글은 https://moreal.hashnode.dev/vitepress-localsearch-debugging 에 있습니다. 내용은 같지만 이미지 호스팅용으로 Hashnode를 사용한 꼴이 되었으므로 예의상(?) 남깁니다 🙏
어제는 Zenn이라는 일본 기술 블로그 서비스의 트렌드 글을 알려주는 봇을 계획하고 구현하려고 보고 있었습니다. ActivityPub 프로토콜을 대상으로 하는 봇이었기 때문에 BotKit을 살펴보았고 BotKit에서 제공하는 메소드가 무엇을 하는지 알기 위해 Fedify문서까지 찾아보게 되었습니다. 그러다 Fedify 문서에서 검색하는 데 검색 기능이 깨진 것이 보여서 이슈로 남겼고, VitePress에도 이슈로 남겨달라는 부탁(?)을 받아 이슈의 내용을 적기 위해 조사를 하다가 PR을 올리기 까지, 야크 쉐이빙을 했다는 것이 이 글의 내용입니다 🤣
버그 재현하기 (원인 변수 확인하기)
VitePress 저장소에 이슈를 적을 때 본문에 어떻게 재현하는지와 재현되는 상황 특정, 왜 발생하는지 적어야 하므로 여러가지 시도를 하면서 해당 문제가 발생하는 시도를 해봤습니다.
처음에는 Fedify 이슈에 올렸던 것처럼 코드 블록이 있는 지점부터 검색 결과에 나오지 않았으므로 코드 블록이 포함되어 있으면 검색 결과에 포함되지 않는 것 같다는 추정을 하였습니다.
Fedify 문서는 사용하는 플러그인이 많아 피드백을 얻는데 시간이 걸렸습니다. 임시 디렉터리(/tmp
)에 프로젝트를 초기화 하고 Fedify에서 사용하는 vitepress@1.6.3
패키지를 설치하였습니다. 그리고 yarn vitepress init
명령어를 실행하여 기본 템플릿으로 문서들을 생성하고 .vitepress/config.mts
에서 search.provider
값을 "local"
로 설정하였습니다. 그리고 Fedify 문서에 있는 헤딩을 가져와 추가했지만 검색이 잘 되었습니다.
추가적으로 환경을 가져와 보기로 했습니다. 마크다운 관련 설정을 가져가면서 @shikijs/vitepress-twoslash
부터 적용해봤는데 버그가 발생하지 않았습니다. 이어 마크다운 문법 관련 플러그인들도 추가했습니다. 그랬더니 버그가 재현되었습니다. 그 다음에는 마크다운 플러그인을 하나하나 빼면서 어떤 플러그인이 영향을 주는지 확인했고 markdown-it-jsr-ref 플러그인을 사용할 때 버그가 재현됨을 확인했습니다.
markdown-it-jsr-ref 플러그인은 `Type`
같이 특정 타입이나 함수를 지칭하는 마크다운을 작성했을 때 JSR에 있는 문서로 연결해주는 플러그인 입니다. HTML 출력이 어떻게 변하는 지 생각해보면 <code>
태그로만 감싸져 있던 부분을 <a>
로 추가로 감싸게 되는 것인데 이에 맞춰 ## With <a>a tag</a> heading
와 ## With <code>code tag</code> heading
간단한 두 테스트 케이스를 만들었고 전자만 “With “ 까지만 검색 결과에 나오는 것을 확인했습니다.
버그가 재현되는 테스트 케이스를 정의하고 나니 VitePress의 버그임이 명확하다고 느껴졌습니다. CommonMark 스펙에 헤딩에 <a>
태그가 들어가면 안 된다 같은 부분은 찾지 못 했기 때문입니다.
버그 수정하기
VitePress의 코드 베이스를 이해하고 있는 상황이 아니므로 어떤 코드가 영향을 주는지부터 찾아야 했습니다. search.provider
설정의 값으로 ”local”
을 사용하고 있으므로 local이라는 키워드로 검색해봤습니다. 그랬을 때 localSearchPlugin.ts
이라는 파일이 눈에 들어왔습니다. 내용을 봤을 때 MiniSearch 라는 검색 라이브러리를 활용하고 있는 것을 보니 찾던 것이 맞아 보였습니다.
우선 clearHtmlTags
함수가 검색 결과로 보여줄 텍스트를 위해 HTML 태그들을 모두 지워주는 것 같아 여기에 breakpoint를 걸고 디버거를 타고 가면서 상위 splitPageIntoSections
함수를 이해했습니다.
headingContentRegex
라는 정규식을 활용하는 라인에서 두 테스트케이스의 처리 결과가 달랐습니다. headingContentRegex
정규식을 이해해보면 헤딩을 입력으로 받고 두 부분을 캡쳐하려고 하는데, 첫번째는 <a>
태그 앞의 내용이고 변수 명으로는 title
, 검색 결과로 보이는 부분입니다. 두 번째는 <a>
태그의 속성 중 href
의 내용을 캡쳐하는데 그 값이 #
으로 시작해야 했습니다.
const headingContentRegex = /(.*?)<a.*? href="#(.*?)".*?>.*?<\/a>/i
#
으로 시작하는 것을 가져오는 것은 앵커를 추출하려는 것이고 VitePress는 헤딩의 끝에 임의로 앵커를 넣어주고 있었습니다. Fedify 문서 중 헤딩의 HTML 값을 인용하면 아래와 같습니다.
Implement the <a href="https://jsr.io/@fedify/fedify@1.6.2/doc/federation/~/KvStore"><code>KvStore</code></a> interface <a class="header-anchor" href="#implement-the-kvstore-interface" aria-label="Permalink to "Implement the `KvStore` interface""></a>
위 정규식에 테스트 케이스를 대입하면 아래와 같이 결과를 반환합니다. 반환된 배열의 두 번째 값을 보면 With
까지만 반환 되었고 세 번째 값을 보면 앵커는 제대로 가져온 것을 확인할 수 있었습니다.
>>> /(.*?)<a.*? href="#(.*?)".*?>.*?<\/a>/i.exec('With <a href="https://example.com">a tag</a> heading <a href="#anchor"></a>')
Array(3) [ 'With <a href="https://example.com/">a tag</a> heading <a href="#anchor"></a>', "With ", "anchor" ]
상황을 정리하면 헤딩의 끝에 붙는 <a>
태그의 href
값과 그 <a>
태그의 앞부분을 가져오는 것이 본래 의도이나 제대로 동작하지 않는 것으로 이해할 수 있었습니다.
문제는 첫 번째 캡쳐 그룹인 (.*?)
중 ?
기호가 문제였습니다. MDN의 정규표현식 문서에 따르면 ?
기호는 *
, +
, ?
와 같은 기호 뒤에 사용되었을 때 해당 기호를 non-greedy 하게 만든다고 합니다.
If used immediately after any of the quantifiers
*
,+
,?
, or{}
, makes the quantifier non-greedy (matching the minimum number of times), as opposed to the default, which is greedy (matching the maximum number of times).
정규 표현식을 완전히 이해하고 있지 않았던지라 확실한 판단은 아니지만, 위 인용문과 함께 이해했을 때 non-greedy 하기 때문에 첫 번째 캡쳐 그룹이 가져갈 수 있는 최대치인 ”With <a href=”https://example.com”>a tag</a> heading “
을 가져가지 않고 최소치인 ”With “
만을 가져가서 이 문제가 발생했다고 이해했습니다. 나머지 부분은 뒷부분은 <a.*?
부분이 가져가게 됩니다. 소괄호로 감싸 캡쳐 그룹으로 만들어주어 테스트 해보면 아래처럼 나오니 맞는 것 같습니다.
>>> /(.*?)<a(.*?) href="#(.*?)".*?>.*?<\/a>/i.exec('With <a href="https://example.com">a tag</a> heading <a href="#anchor"></a>')
Array(4) [ 'With <a href="https://example.com">a tag</a> heading <a href="#anchor"></a>', "With ", ' href="https://example.com">a tag</a> heading <a', "anchor" ]
그래서 첫번째 캡쳐 그룹을 다시 greedy하게 만들어 줄 필요가 있었고 단순히 ?
기호를 하나를 지웠고 테스트 해보니 잘 동작하는 것으로 보였습니다. 와, 버그 수정 완료!
>>> /(.*)<a.*? href="#(.*?)".*?>.*?<\/a>/i.exec('With <a href="https://example.com">a tag</a> heading <a href="#anchor"></a>')
Array(3) [ 'With <a href="https://example.com">a tag</a> heading <a href="#anchor"></a>', 'With <a href="https://example.com">a tag</a> heading ', "anchor" ]
PR 만들기
PR을 올릴 시간입니다. PR을 열 때 메인테이너에게 무슨 버그인지 충분히 설명할 필요가 있습니다. 이를 위해 별도의 두 저장소를 만들어 GitHub Pages에 배포하는 방식으로 문서를 두 개 만들었습니다. 하나는 기존 1.6.3 버전에서 버그를 확인할 수 있도록 하였고, 나머지 하나는 ?
기호를 없애는 변경을 더해 이 PR의 변경사항으로 버그를 고칠 수 있음을 확인할 수 있도록 하였습니다.
스크린샷을 첨부하였으나 전체를 찍기 애매해서 전문은 이슈에서 확인 부탁드립니다 🙏
한 1시간 정도 준비해서 올렸는데 머지는 순식간에 되어서 빠른 확인에 감사하면서도 그냥 뭔가 머쓱했습니다 😅
회고
잘 된 점
- 야크 쉐이빙이긴 하지만 즐거웠습니다.
- 근래에 나온 Zed Debugger를 사용하여 디버깅 했는데 좋은 경험이었습니다.
잘 안 된 점 (아쉬운 점)
- 이와 관련된 버그를 해결 중인 이슈나 PR이 이미 존재하는 지 알기 어려웠습니다.
- PR 설명을 준비하는데 꽤나 많은 시간을 소모했습니다.
- 재현 환경을 구성하는데 꽤나 많은 시간을 소모했습니다.
- 이 회고글을 작성하는데도 2시간 정도가 걸렸습니다.
- 정규표현식을 온전히 이해하지 못 하여
<a>
태그에서 정확히 파악하지 정확한 판단하지 못 했습니다.
개선할 점
- 글로 적어보니 꽤나 절차적인데 Agent로 이 과정을 자동화 할 수 있을까?
- 재현 환경의 구성과 배포, 수정된 환경의 배포를 쉽게 할 수 있을까?
- 이미 누가 작업하고 있는 작업인지 여부를 쉽게 확인할 수 있을까?
- 정규표현식을 온전히 이해하면 좋을 것 같습니다.