블로그에 검색과 갤러리를 붙이고, 다크모드를 다시 썼다

26년 08월 03일

블로그가 커지기 전에 미리 검색을 넣었다

포스트가 빨리 늘 거라는 예감이 있었다.

에이전트가 일기를 매일 쓰기 시작하면 목록이 금방 길어질 테고 그러면 검색이 없는 블로그는 불편해진다.

늦게 붙이는 것보다 지금 구조를 잡아두는 게 낫다고 판단해서 Pagefind를 도입했다.

Astro 빌드가 끝난 뒤 Pagefind CLI가 dist/를 인덱싱하는 구조다.

package.json의 build 스크립트 한 줄만 바꿨다.

"build": "astro build && pagefind --site dist"

설정 파일 없이도 동작해서 유지비가 적다.

10,000 페이지까지 인덱스 페이로드가 300KB 이하라는 점도 마음에 들었다.

메타를 세 곳에 나눠 심기

Pagefind는 가장 가까운 data-pagefind-body 기준으로 인덱싱한다.

그런데 내 블로그 상세 페이지는 하나의 글이 여러 HTML 요소에 걸쳐 있다.

제목은 <article>에, 본문은 <section>에, 커버 이미지는 <footer>에 각각 메타를 심었다.

커버가 본문에 직접 렌더되지 않으니 빌드타임에 에셋 URL을 해석해서 footer 메타로 노출시키는 게 핵심이었다.

한국어 stemming 미지원

Pagefind가 ko 언어에 대해 “doesn’t support stemming for the language ko” 경고를 뱉는다.

검색 자체는 동작하지만 어형 변화를 정규화하지 않는다.

하다/했다/하는을 다른 활용형으로 검색하면 누락이 생긴다.

정확한 형태 그대로 토큰 매칭된다고 가정하고 UX를 설계했다.

JSON 청크 페이징도 같이 넣었다

검색과 함께 리스트 로딩도 미리 준비했다.

페이지 단위 JSON 정적 엔드포인트로 페이지 단위 JSON을 뽑아내고 클라이언트에서 무한스크롤로 가져오는 구조다.

포스트가 수백 개가 되어도 첫 화면은 가볍게 뜬다.

이전까지 연속된 이미지를 가로로 배치하려면 인용문 블록을 우회해서 쓰는 수밖에 없었다.

> ![a](...) ![b](...) 식으로 blockquote 안에 우겨넣는 방식이었는데, 캡션 처리도 애매했다.

그래서 :::gallery{cols="2"} 컨텍스트 디렉티브를 만들었다.

마크다운 파서가 이미지 주변에 <p> 래퍼를 만들고 줄바꿈 패턴에 따라 <img>들이 서로 다른 depth에 흩어지는 게 기술적 걸림돌이었다.

결국 mdast 단계에서 figure로 매핑하고 hast 단계에서 자손 img를 평탄화하는 두 개의 remark/rehype 플러그인 쌍으로 해결했다.

한 단계로는 안 된다.

두 단계가 필요한 이유는 트리 구조가 파싱 단계마다 달라지기 때문이다.

rehype-figure로 캡션 정식화

단독 이미지도 처리했다.

기존엔 캡션을 넣을 방법이 없어서 blockquote 안에 이미지와 텍스트를 우회 사용했다.

@microflash/rehype-figure v2.1.4를 붙여서 ![캡션](이미지) 패턴을 정식 <figure>/<figcaption>으로 변환했다.

패키지 선택이 조금 헷갈렸다.

같은 이름의 패키지가 세 종류 있었고 그중 하나는 unified 11과 호환되지 않았다.

결국 unified 11 스택과 의존성이 정확히 일치하는 포크 버전을 골랐다.

플러그인 순서도 중요했다.

피규어 변환 플러그인이 갤러리 변환 플러그인보다 먼저 실행되어야 gallery 안 이미지도 figure로 감싸진 뒤 평탄화된다.

변환 범위

기존 글에서 blockquote로 우회하던 이미지 9곳을 단독 이미지 + alt 캡션 구조로 바꿨다.

2-이미지 blockquote 1곳은 :::gallery + 공유 캡션으로 변환했다.

그 과정에서 기존 image라고만 적힌 무의미 alt 6곳도 의미 있는 alt로 교체했다.

비디오 blockquote 6곳은 rehype-figure가 <img>만 처리하므로 그대로 뒀다.

다크모드 스위치: Lottie 걷어내고 순수 CSS로

다크모드 토글에 Lottie 애니메이션이 들어가 있었다.

보이는 건 예쁜데 무겁다.

@lottiefiles/dotlottie-react 의존성을 통째로 들고 있었고, 애니메이션 파일도 별도였다.

그런데 의존성 자체를 삭제하면 안 됐다.

검색 결과가 비었을 때 빈 상태를 표시하는 Search.tsx가 empty.lottie를 쓰고 있었기 때문이다.

삭제 대상은 딱 두 개로 좁혔다.

첫째는 public/lotties/dark-mode-switch.lottie orphan 에셋이다.

둘째는 다크모드 스위치 컴포넌트 안의 로티 애니메이션 사용부다.

컴포넌트를 순수 CSS로 전면 재작성했다.

sun/moon/stars/clouds 애니메이션을 CSS 키프레임으로 구현했다.

크기도 90×46px에서 52×28px로 줄였다.

knob, 광선 반경, 구름, 별, 크레이터, glow를 한꺼번에 스케일했다.

부분적으로 스케일하면 어긋난다.

광선은 트랙 밖으로 잘리지 않도록 반경과 길이를 다시 계산해야 했다.

글쓰기 규칙을 문서로 남겼다

이 블로그에 글을 쓰는 주체가 사람만이 아니다.

에이전트도 쓴다.

그래서 contents/AGENTS.md에 글쓰기 규칙을 정리해뒀다.

문체, 구조, 허용하는 표현, 금지하는 표현을 명시했다.

CJK 한자가 본문에 섞이면 Husky pre-commit이 커밋을 차단한다.

한국어 Hangul은 허용하지만 표의문자는 안 된다.

매일 아침 에이전트가 어제 작업 기록을 읽고 블로그 일기로 바꿔서 푸시하는 자동 발행 파이프라인도 구조화했다.

humanize-kr 스킬을 이식했다

Claude Code 환경에서 쓰던 한글 휴머나이저 스킬을 Hermes로 가져왔다.

원래 5단계 파이프라인이었던 걸 3인 구조로 단순화했다.

진단, 윤문, 마무리 세 역할로 나눴다.

입력 텍스트를 정량 분석해서 light, standard, heavy 중 하나의 경로를 자동으로 판정한다.

잘 쓴 글은 가볍게 손대고, AI 티가 심한 글은 정밀하게 다듬는 식이다.

이 블로그 일기 변환에도 적용했다.

AI가 쓴 초안에서 기계적인 리듬과 상투적 어휘를 걸러내는 용도다.

의미, 사실, 수치, 고유명사는 한 글자도 바꾸지 않고 문체만 다듬는다.