Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ _plugins/ reading_time.rb (한·영 읽기 시간 계산)
post_description.rb (글의 page.description 채우기)
related_posts.rb (page.related, 그리고 이전/다음 글 링크)
scrollable_tables.rb (넓은 표를 스크롤 컨테이너로 감싸기)
search_index.rb (search.json용 plain_text 필터)
css/ main.scss(Sass 진입점) · search.css(검색 페이지 전용)
js/ main.js(테마 토글·코드 복사·목차·메뉴·이미지 확대 등)
search.js(검색창 동작)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ _plugins/ reading_time.rb (KO/EN-aware read time)
post_description.rb (fills page.description for posts)
related_posts.rb (page.related, and the prev/next links)
scrollable_tables.rb (wraps wide tables so they scroll)
search_index.rb (plain_text filter for search.json)
css/ main.scss (Sass entry point) · search.css (search page only)
js/ main.js (theme toggle, code-copy, TOC, menu, image zoom…)
search.js (drives the search box)
Expand Down
42 changes: 42 additions & 0 deletions _plugins/search_index.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true
#
# `plain_text` — turns a rendered post into the text search.json carries.
#
# Replaces `strip_html | strip_newlines`, which got two things wrong:
#
# 1. It left HTML entities encoded. kramdown escapes a literal `>` in prose to
# `>`, so the index held `"음료 > 탄산음료"` and the search page printed
# that verbatim to the reader.
# 2. It removed tags without leaving anything behind, so `<p>끝</p><p>시작</p>`
# indexed as `끝시작` — one token that matches neither word.

require "cgi"

module SearchIndex
# Elements whose *content* is not prose. Removed outright, not unwrapped.
NON_PROSE = %r{<(script|style)\b[^>]*>.*?</\1>}mi
COMMENT = /<!--.*?-->/m
# Tags that end a run of text. Anything else is inline (`<em>`, `<code>`, `<a>`)
# and is unwrapped with no space, so `<em>강조</em>된` stays one word.
BOUNDARY = %r{</?(?:p|div|li|ul|ol|dl|dt|dd|h[1-6]|blockquote|pre|table|thead
|tbody|tr|td|th|section|article|header|footer|figure|figcaption
|br|hr)\b[^>]*>}xi

def self.plain_text(html)
text = html.to_s.gsub(NON_PROSE, " ").gsub(COMMENT, " ")
# Strip before decoding, never after. A post that quotes markup contains
# `&lt;script&gt;` as text; decoding first would make it a real tag and the
# strip would then delete the words the author actually wrote.
text = text.gsub(BOUNDARY, " ").gsub(/<[^>]*>/, "")
CGI.unescapeHTML(text).gsub(/\s+/, " ").strip
end
end

module SearchIndexFilter
def plain_text(input)
SearchIndex.plain_text(input)
end
end

# Guarded so test/ can require this file without Liquid loaded.
Liquid::Template.register_filter(SearchIndexFilter) if defined?(Liquid::Template)
5 changes: 4 additions & 1 deletion _sass/_dark.scss
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,10 @@
}
.search-result { border-bottom-color: $border; }
.search-result h3 a { color: $fg; &:hover { color: $link; } }
.search-status, .post-category { color: $fg-muted; }
// .result-tags was missing here, so it kept search.css's #767676 — a value
// chosen to clear 4.5:1 on white, which lands at 4.2:1 on #0d1117 and fails AA
// for text this size.
.search-status, .post-category, .result-tags { color: $fg-muted; }
.result-snippet, .search-result p { color: $fg; }
// #fff on #b8860b is 3.25:1, and bold 0.9em is not large text. Dark text on the
// same fill is 7.9:1.
Expand Down
56 changes: 41 additions & 15 deletions docs/tech-doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Pages가 그대로 서빙.**
|------|------|-----------|
| 정적 사이트 생성기 | Jekyll 4.4 (`Gemfile`, Ruby 3.3+), kramdown(GFM 입력) | 마크다운 글을 HTML로 굽는 본체. `Gemfile`은 Ruby의 의존성 목록(= `package.json`에 해당) |
| 플러그인(gem) | `jekyll-paginate`, `jekyll-sitemap`, `jekyll-feed` | 각각 목록 페이지 나누기, `sitemap.xml`(검색엔진용 지도), `feed.xml`(RSS 구독) 자동 생성 |
| 로컬 플러그인(`_plugins/`) | `reading_time.rb`(한·영 읽기시간 계산), `lazy_images.rb`(`<img>`에 lazy-load 부여), `post_description.rb`(글의 검색 설명문 생성), `related_posts.rb`(공유 태그로 관련 글 선정 + 글 끝 이전/다음 링크), `scrollable_tables.rb`(넓은 표를 가로 스크롤 컨테이너로 감싸기) | 우리가 직접 만든 Ruby 확장. 이것 때문에 GitHub Pages 기본 빌드 대신 Jekyll을 직접 돌린다(§1 아래 참고) |
| 로컬 플러그인(`_plugins/`) | `reading_time.rb`(한·영 읽기시간 계산), `lazy_images.rb`(`<img>`에 lazy-load 부여), `post_description.rb`(글의 검색 설명문 생성), `related_posts.rb`(공유 태그로 관련 글 선정 + 글 끝 이전/다음 링크), `scrollable_tables.rb`(넓은 표를 가로 스크롤 컨테이너로 감싸기), `search_index.rb`(검색 색인용 평문 변환) | 우리가 직접 만든 Ruby 확장. 이것 때문에 GitHub Pages 기본 빌드 대신 Jekyll을 직접 돌린다(§1 아래 참고) |
| 신택스 하이라이팅 | Rouge(서버사이드, kramdown 내장) | 코드 블록에 색을 입히는 작업을 **빌드 때 미리** 한다(브라우저 부담 0). 색 테마는 `_sass/_syntax.scss` |
| 수식 | kramdown `math_engine: mathjax` → MathJax 3, 포스트별 `use_math`로 로드 | 수학 기호를 브라우저에서 예쁘게 그려 주는 라이브러리. 수식이 있는 글에서만 불러온다 |
| 스타일 | Sass(`_sass/`), 벤더링된 Bourbon + Neat 그리드 프레임워크 | "벤더링"은 외부 라이브러리를 저장소 안에 복사해 둔 것. `jekyll-sass-converter` 2.x(libsass)로 **고정** — 3.x(dart-sass)는 Bourbon/Neat의 구식 `/` 나눗셈 문법에서 에러 |
Expand Down Expand Up @@ -231,9 +231,19 @@ MathJax 설정은 `head.html`에 있고 `{% if page.use_math %}`로 감싸 **프
직접 뒤지는** 방식이다.

- **`search.json`** 은 Liquid 템플릿(`layout: null`, 즉 HTML 틀 없이 순수 JSON만 출력)으로,
빌드 때 모든 포스트를 `{title, url, date, category, tags, snippet, content}` 형태로
뽑아낸다. 여기서 `snippet`은 결과 카드에 보여줄 40단어짜리 발췌, `content`는 매칭에 쓰는
**HTML을 제거한 전체 본문**이다.
빌드 때 모든 포스트를 `{title, url, date, category, tags, content}` 형태로 뽑아낸다.
`content`는 **평문으로 만든 본문 전체**이며, 매칭과 표시에 모두 이것 하나만 쓴다.
- **`plain_text` 필터**(`_plugins/search_index.rb`)가 렌더된 HTML을 평문으로 바꾼다.
`strip_html | strip_newlines`를 대신하는데, 그쪽이 두 가지를 놓쳤다.
- **엔티티를 그대로 뒀다.** kramdown은 본문의 `>`를 `&gt;`로 escape하므로, 색인에
`"음료 &gt; 탄산음료"`가 들어가 검색 결과에 그대로 찍혔다.
- **블록 경계에 아무것도 남기지 않았다.** `<p>끝</p><p>시작</p>`이 `끝시작`이라는 한
덩어리로 색인돼 두 단어 어느 쪽으로도 검색되지 않았다.

순서가 중요하다 — **태그를 먼저 지우고 그다음에 엔티티를 디코딩한다.** 반대로 하면 마크업을
인용한 글의 `&lt;script&gt;`가 진짜 태그가 되고, 이어지는 태그 제거가 저자가 쓴 글자를
지운다. 인라인 태그(`<em>`, `<code>`)는 공백 없이 벗겨 `<em>강조</em>된`이 한 단어로
남는다. `test/test_search_index.rb`가 이 경계들을 고정한다.
- **왜 발췌가 아니라 전체 본문을 색인하나** — `simple-jekyll-search`는 형태소 분석 없이
단순 부분문자열 매칭을 한다. **색인에 없는 글자는 못 찾는다.** 발췌만 색인하면 본문
중·후반에만 나오는 단어("어텐션", "트랜스포머" 같은)는 검색 결과가 0건이 된다. `content`로
Expand All @@ -242,15 +252,25 @@ MathJax 설정은 `head.html`에 있고 `{% if page.use_math %}`로 감싸 **프
MB 단위, gzip 후 그 3분의 1 아래). `/search/`에서만 내려받으므로 다른 페이지 속도엔 영향이
없다. 실제 값은 `curl -so /dev/null -w '%{size_download}' <url>/search.json`로 확인한다.

> **비대칭에 주의.** 매칭은 `content`(전체)로 하고 표시는 `snippet`(앞 40단어)으로 한다.
> 그래서 본문 깊은 곳에서 걸린 검색어는 결과 카드에 보이지 않는다(§9).
- **`js/search.js`** 가 `simple-jekyll-search`(CDN 버전 **고정 + SRI**: `1.10.0`)를
`search.md`의 `#search-input` 입력칸에 연결한다. 매칭된 키워드를 `<mark>`로 강조하고
결과 개수를 라이브 상태줄에 표시한다. 강조 처리는 결과가 다 그려진 뒤 **디바운스**로 단
한 번만 실행한다 — 키 입력마다 `setTimeout`을 쌓으면 타이머가 서로 경합해 화면이 깜빡인다.

> **용어 — 디바운스(debounce).** 사용자가 빠르게 연속으로 일으키는 이벤트(타이핑 등)에서,
> 마지막 입력 뒤 잠깐 멈출 때까지 기다렸다가 **딱 한 번만** 함수를 실행하는 기법이다.
`search.md`의 `#search-input` 입력칸에 연결한다.
- **발췌는 매치 위치를 중심으로 자른다.** 이게 검색 결과의 핵심이다. 색인은 본문 전체인데
카드에 고정된 앞부분을 보여 주면, 검색어는 거의 항상 그 밖에 있다 — 한국어 질의는 매치가
본문 300자 이후에만 있는 경우가 대다수여서, **맞는 결과가 강조 하나 없는 엉뚱한 결과처럼
보였다.** 그래서 `templateMiddleware`(라이브러리 훅. 결과마다 `{필드}` 하나당 한 번 호출되며
필드 값 전체를 받아 렌더할 문자열을 돌려준다)에서 매치 주변 창을 잘라낸다. 본문 전체는 JS까지
오고 창만 DOM에 들어간다.
- 질의가 두 단어 이상이면 매치마다 창을 만들어 보고 **서로 다른 단어를 가장 많이 담는 창**을
고른다. 첫 매치만 쓰면 "vibe coding"에서 멀리 떨어진 `coding` 하나만 걸려 반쪽짜리
발췌가 나온다.
- 단어 중간에서 자르지 않으려 공백을 찾지만 **12자 안에서만** 찾는다. 한국어는 어절 사이
공백이 없어 무제한으로 찾으면 매치를 지나쳐 창을 삼킨다. 상한이 있으면 글자 단위 절단으로
자연스럽게 내려앉는다.
- 미들웨어가 돌려주는 값은 라이브러리가 **HTML로 삽입**하므로, 이 파일이 직접 escape하고
`<mark>`만 스스로 넣는다.
- **결과 개수는 상한에 닿으면 총계를 말하지 않는다.** 라이브러리는 `limit`개를 찾으면 스캔을
멈추므로 진짜 총계를 모른다. 그래서 상한에 닿으면 "Showing the first 10 matches."로
적는다 — "10 posts found."는 셈이 아니라 추측이다.
- `category`/`tags`도 색인에 들어가므로 제목·본문뿐 아니라 메타데이터로도 검색된다.
색인에는 **글(`site.posts`)만** 들어간다 — `search.json`이 `site.posts`를 순회하므로
About·Search·index 같은 페이지는 애초에 후보가 아니다.
Expand Down Expand Up @@ -373,6 +393,12 @@ Bourbon → base/ → Neat → _layout → _post → _tags → _syntax(Rouge 코
확보했지만, 더 정교한 검색이 필요해지면 Lunr 등으로 교체를 검토한다.
- **검색 색인 크기** — 전체 본문 색인이라 `search.json`이 글 수에 비례해 커진다(§5).
`/search/`에서만 로드되긴 하나, 계속 늘면 색인 분할이나 서버사이드 검색을 고려한다.
- **검색 결과의 매치가 안 보인다** — 색인은 본문 전체인데 결과 카드는 앞 40단어 발췌만
보여 준다. 본문 깊은 곳에서 걸린 검색어는 카드에 없고 강조도 안 되므로, 맞는 결과가
엉뚱해 보인다. 매치 위치를 중심으로 잘라내는 발췌가 필요하다(§5).
- **검색 결과에 관련도 순위가 없다** — `simple-jekyll-search`는 `site.posts` 순서로 훑다가
`limit`개를 찾으면 멈춘다. 그래서 결과는 **최신순이고, 제목이 걸린 글이 본문에 한 번 스친
글보다 위로 오지 않는다.** 상한 때문에 정렬 훅(`sortMiddleware`)만으로는 고칠 수 없다 —
이미 잘려 나간 뒤에 정렬하기 때문이다. 제대로 하려면 매칭·정렬·자르기를 직접 들고 있어야
하고, 그 시점에는 라이브러리를 걷어내는 게 맞다(현재 쓰는 기능은 페치·부분문자열 매칭·템플릿
치환뿐이다).
- **수식이 발췌에 날것으로 보인다** — 색인은 렌더된 HTML을 평문화한 것이라 MathJax 구분자가
`\(O(L^2)\)` 형태로 남는다. 발췌가 그 구간에 걸리면 그대로 찍힌다. 색인에서 수식 구간을
지우면 깔끔해지지만 수식 안의 문자는 검색되지 않게 된다.
Loading
Loading