diff --git a/README.ko.md b/README.ko.md index 3f5ebea..6366956 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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(검색창 동작) diff --git a/README.md b/README.md index 58fdd43..6a54425 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/_plugins/search_index.rb b/_plugins/search_index.rb new file mode 100644 index 0000000..f5e2260 --- /dev/null +++ b/_plugins/search_index.rb @@ -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 `
끝
시작
` +# 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 (``, ``, ``)
+ # and is unwrapped with no space, so `강조된` 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
+ # `<script>` 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)
diff --git a/_sass/_dark.scss b/_sass/_dark.scss
index fdbf88f..5236ca6 100644
--- a/_sass/_dark.scss
+++ b/_sass/_dark.scss
@@ -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.
diff --git a/docs/tech-doc.md b/docs/tech-doc.md
index 0b6b66e..d5bd35a 100644
--- a/docs/tech-doc.md
+++ b/docs/tech-doc.md
@@ -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`(`
`에 lazy-load 부여), `post_description.rb`(글의 검색 설명문 생성), `related_posts.rb`(공유 태그로 관련 글 선정 + 글 끝 이전/다음 링크), `scrollable_tables.rb`(넓은 표를 가로 스크롤 컨테이너로 감싸기) | 우리가 직접 만든 Ruby 확장. 이것 때문에 GitHub Pages 기본 빌드 대신 Jekyll을 직접 돌린다(§1 아래 참고) |
+| 로컬 플러그인(`_plugins/`) | `reading_time.rb`(한·영 읽기시간 계산), `lazy_images.rb`(`
`에 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의 구식 `/` 나눗셈 문법에서 에러 |
@@ -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은 본문의 `>`를 `>`로 escape하므로, 색인에
+ `"음료 > 탄산음료"`가 들어가 검색 결과에 그대로 찍혔다.
+ - **블록 경계에 아무것도 남기지 않았다.** `끝
시작
`이 `끝시작`이라는 한
+ 덩어리로 색인돼 두 단어 어느 쪽으로도 검색되지 않았다.
+
+ 순서가 중요하다 — **태그를 먼저 지우고 그다음에 엔티티를 디코딩한다.** 반대로 하면 마크업을
+ 인용한 글의 `<script>`가 진짜 태그가 되고, 이어지는 태그 제거가 저자가 쓴 글자를
+ 지운다. 인라인 태그(``, ``)는 공백 없이 벗겨 `강조된`이 한 단어로
+ 남는다. `test/test_search_index.rb`가 이 경계들을 고정한다.
- **왜 발췌가 아니라 전체 본문을 색인하나** — `simple-jekyll-search`는 형태소 분석 없이
단순 부분문자열 매칭을 한다. **색인에 없는 글자는 못 찾는다.** 발췌만 색인하면 본문
중·후반에만 나오는 단어("어텐션", "트랜스포머" 같은)는 검색 결과가 0건이 된다. `content`로
@@ -242,15 +252,25 @@ MathJax 설정은 `head.html`에 있고 `{% if page.use_math %}`로 감싸 **프
MB 단위, gzip 후 그 3분의 1 아래). `/search/`에서만 내려받으므로 다른 페이지 속도엔 영향이
없다. 실제 값은 `curl -so /dev/null -w '%{size_download}' /search.json`로 확인한다.
- > **비대칭에 주의.** 매칭은 `content`(전체)로 하고 표시는 `snippet`(앞 40단어)으로 한다.
- > 그래서 본문 깊은 곳에서 걸린 검색어는 결과 카드에 보이지 않는다(§9).
- **`js/search.js`** 가 `simple-jekyll-search`(CDN 버전 **고정 + SRI**: `1.10.0`)를
- `search.md`의 `#search-input` 입력칸에 연결한다. 매칭된 키워드를 ``로 강조하고
- 결과 개수를 라이브 상태줄에 표시한다. 강조 처리는 결과가 다 그려진 뒤 **디바운스**로 단
- 한 번만 실행한다 — 키 입력마다 `setTimeout`을 쌓으면 타이머가 서로 경합해 화면이 깜빡인다.
-
- > **용어 — 디바운스(debounce).** 사용자가 빠르게 연속으로 일으키는 이벤트(타이핑 등)에서,
- > 마지막 입력 뒤 잠깐 멈출 때까지 기다렸다가 **딱 한 번만** 함수를 실행하는 기법이다.
+ `search.md`의 `#search-input` 입력칸에 연결한다.
+- **발췌는 매치 위치를 중심으로 자른다.** 이게 검색 결과의 핵심이다. 색인은 본문 전체인데
+ 카드에 고정된 앞부분을 보여 주면, 검색어는 거의 항상 그 밖에 있다 — 한국어 질의는 매치가
+ 본문 300자 이후에만 있는 경우가 대다수여서, **맞는 결과가 강조 하나 없는 엉뚱한 결과처럼
+ 보였다.** 그래서 `templateMiddleware`(라이브러리 훅. 결과마다 `{필드}` 하나당 한 번 호출되며
+ 필드 값 전체를 받아 렌더할 문자열을 돌려준다)에서 매치 주변 창을 잘라낸다. 본문 전체는 JS까지
+ 오고 창만 DOM에 들어간다.
+ - 질의가 두 단어 이상이면 매치마다 창을 만들어 보고 **서로 다른 단어를 가장 많이 담는 창**을
+ 고른다. 첫 매치만 쓰면 "vibe coding"에서 멀리 떨어진 `coding` 하나만 걸려 반쪽짜리
+ 발췌가 나온다.
+ - 단어 중간에서 자르지 않으려 공백을 찾지만 **12자 안에서만** 찾는다. 한국어는 어절 사이
+ 공백이 없어 무제한으로 찾으면 매치를 지나쳐 창을 삼킨다. 상한이 있으면 글자 단위 절단으로
+ 자연스럽게 내려앉는다.
+ - 미들웨어가 돌려주는 값은 라이브러리가 **HTML로 삽입**하므로, 이 파일이 직접 escape하고
+ ``만 스스로 넣는다.
+- **결과 개수는 상한에 닿으면 총계를 말하지 않는다.** 라이브러리는 `limit`개를 찾으면 스캔을
+ 멈추므로 진짜 총계를 모른다. 그래서 상한에 닿으면 "Showing the first 10 matches."로
+ 적는다 — "10 posts found."는 셈이 아니라 추측이다.
- `category`/`tags`도 색인에 들어가므로 제목·본문뿐 아니라 메타데이터로도 검색된다.
색인에는 **글(`site.posts`)만** 들어간다 — `search.json`이 `site.posts`를 순회하므로
About·Search·index 같은 페이지는 애초에 후보가 아니다.
@@ -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)\)` 형태로 남는다. 발췌가 그 구간에 걸리면 그대로 찍힌다. 색인에서 수식 구간을
+ 지우면 깔끔해지지만 수식 안의 문자는 검색되지 않게 된다.
diff --git a/js/search.js b/js/search.js
index e545174..d122168 100644
--- a/js/search.js
+++ b/js/search.js
@@ -1,18 +1,20 @@
/* Client-side search over /search.json (simple-jekyll-search).
*
- * Refactor notes:
- * - search.json carries a 40-word `snippet` for display *and* each post's full
- * body in `content`, plus `category` and `tags`. `content` is what makes this
- * full-text rather than title-only: no `searchFields` is configured, so
- * simple-jekyll-search matches every field. That is deliberate — see
- * "fix(search): index full body for Korean recall".
- * The cost is real and should not be discovered by surprise: search.json is
- * 3.2 MB raw / ~970 KB gzip, fetched only by this page. An earlier version of
- * this comment claimed the body had been dropped for a ~70 KB index; it never
- * was, and the claim was wrong for months.
- * - Keyword highlighting runs once per render via a debounced handler — the old
- * code stacked a fresh setTimeout on every keystroke, racing itself.
- * - A live status line reports match counts for screen readers and sighted users.
+ * The index is every post's full body — that is what makes Korean recall work,
+ * since simple-jekyll-search does plain substring matching and cannot find a
+ * character it never indexed. But the result card used to show a fixed excerpt of
+ * the first 40 words, and a search term is almost never in the first 40 words:
+ * for "어텐션" every matching post matched only past character 300, so every card
+ * showed prose with no visible match and nothing highlighted. Correct results
+ * looked like wrong ones.
+ *
+ * So the excerpt is cut around the match instead. That happens in
+ * `templateMiddleware`, a simple-jekyll-search hook called once per {placeholder}
+ * per result: it receives the full field value and returns what to render, so the
+ * whole body reaches this file and only the window reaches the DOM.
+ *
+ * Everything returned from the middleware is inserted as HTML by the library, so
+ * text is escaped here and the only tags introduced are the s.
*/
(function () {
'use strict';
@@ -22,39 +24,147 @@
var statusEl = document.getElementById('search-status');
if (!searchInput || !resultsContainer) return;
- function escapeRegExp(string) {
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ var LIMIT = 10; // must match the `limit` passed to SimpleJekyllSearch
+ var WINDOW_CHARS = 220; // length of the excerpt shown
+ var LEAD_CHARS = 60; // run-up kept before the first match, for context
+ var SNAP_CHARS = 12; // how far to reach for a space rather than cut a word
+
+ function queryWords() {
+ // The library lowercases and splits on spaces, and requires every word to
+ // appear; matching that here keeps the highlight honest.
+ return searchInput.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
+ }
+
+ var ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"' };
+ function escapeHtml(text) {
+ return text.replace(/[&<>"]/g, function (c) { return ESCAPES[c]; });
+ }
+
+ // Every occurrence of every query word, as non-overlapping [start, end) ranges.
+ // Overlaps are merged so a query like "gpt gpt-4" cannot nest one in
+ // another.
+ function matchRanges(text, words) {
+ var lower = text.toLowerCase();
+ var found = [];
+ words.forEach(function (word) {
+ var from = 0;
+ var at = lower.indexOf(word, from);
+ while (at !== -1) {
+ found.push([at, at + word.length]);
+ from = at + word.length;
+ at = lower.indexOf(word, from);
+ }
+ });
+ found.sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
+
+ var merged = [];
+ found.forEach(function (range) {
+ var last = merged[merged.length - 1];
+ if (last && range[0] <= last[1]) {
+ last[1] = Math.max(last[1], range[1]);
+ } else {
+ merged.push([range[0], range[1]]);
+ }
+ });
+ return merged;
}
- function highlight(keyword) {
- if (!keyword) return;
- var regex = new RegExp('(' + escapeRegExp(keyword) + ')', 'gi');
- var targets = resultsContainer.querySelectorAll('.search-result h3 a, .search-result .result-snippet, .search-result .result-tags');
- targets.forEach(function (el) {
- // Re-derive from the original text each time so highlights don't compound.
- var original = el.getAttribute('data-original') || el.textContent;
- el.setAttribute('data-original', original);
- el.innerHTML = original.replace(regex, '$1');
+ // Escape `text`, wrapping each query-word hit in .
+ function mark(text, words) {
+ var out = '';
+ var cursor = 0;
+ matchRanges(text, words).forEach(function (range) {
+ out += escapeHtml(text.slice(cursor, range[0])) +
+ '' + escapeHtml(text.slice(range[0], range[1])) + '';
+ cursor = range[1];
});
+ return out + escapeHtml(text.slice(cursor));
}
- function updateStatus() {
- var q = searchInput.value.trim();
- if (!q) { statusEl.textContent = ''; return; }
- var n = resultsContainer.querySelectorAll('.search-result').length;
- statusEl.textContent = n === 0
- ? 'No posts match "' + q + '".'
- : n + (n === 1 ? ' post' : ' posts') + ' found.';
+ // Where to cut the body so the first match is visible, with context in front.
+ function windowAround(text, firstMatch) {
+ if (!firstMatch) {
+ return { start: 0, end: Math.min(text.length, WINDOW_CHARS) };
+ }
+ var start = Math.max(0, firstMatch[0] - LEAD_CHARS);
+ var end = Math.min(text.length, start + WINDOW_CHARS);
+
+ // Prefer a word boundary, but only if one is within SNAP_CHARS. Korean prose
+ // has no inter-word spaces, so an unbounded search for one would run past the
+ // match and swallow the window; the cap makes it degrade to a clean character
+ // cut. The extra guards stop a snap from hiding the match it exists to show.
+ if (start > 0) {
+ var next = text.indexOf(' ', start);
+ if (next !== -1 && next - start <= SNAP_CHARS && next < firstMatch[0]) {
+ start = next + 1;
+ }
+ }
+ if (end < text.length) {
+ var prev = text.lastIndexOf(' ', end);
+ if (prev !== -1 && end - prev <= SNAP_CHARS && prev > firstMatch[1]) {
+ end = prev;
+ }
+ }
+ return { start: start, end: end };
+ }
+
+ var MAX_CANDIDATES = 40; // enough for a common word; bounds the work per result
+
+ // With a multi-word query the earliest match is often the wrong place to look:
+ // for "vibe coding" the first "coding" can be chapters away from any "vibe",
+ // and the excerpt then shows one word of a two-word query. So try the window
+ // around each match and keep whichever covers the most distinct words.
+ function bestWindow(text, ranges, words) {
+ if (!ranges.length) {
+ return { start: 0, end: Math.min(text.length, WINDOW_CHARS) };
+ }
+ if (words.length < 2) return windowAround(text, ranges[0]);
+
+ var best = null;
+ var limit = Math.min(ranges.length, MAX_CANDIDATES);
+ for (var i = 0; i < limit; i++) {
+ var bounds = windowAround(text, ranges[i]);
+ var slice = text.slice(bounds.start, bounds.end).toLowerCase();
+ var covered = 0;
+ for (var w = 0; w < words.length; w++) {
+ if (slice.indexOf(words[w]) !== -1) covered++;
+ }
+ // Strictly greater, so ties keep the earliest window — the opening of a
+ // post is likelier to be orienting prose than a passage deep inside it.
+ if (!best || covered > best.covered) best = { bounds: bounds, covered: covered };
+ if (best.covered === words.length) break;
+ }
+ return best.bounds;
+ }
+
+ function excerpt(text, words) {
+ var ranges = matchRanges(text, words);
+ // A post can match on its title or tags alone, with the term absent from the
+ // body. Then there is no match to centre on and the opening lines are the
+ // most useful thing to show.
+ var bounds = bestWindow(text, ranges, words);
+ var slice = text.slice(bounds.start, bounds.end);
+ return (bounds.start > 0 ? '…' : '') +
+ mark(slice, words) +
+ (bounds.end < text.length ? '…' : '');
}
- // Debounce: coalesce bursts of keystrokes into one post-render pass.
- var timer = null;
- function onRendered() {
- if (timer) clearTimeout(timer);
- timer = setTimeout(function () {
- highlight(searchInput.value.trim());
- updateStatus();
- }, 120);
+ function updateStatus() {
+ var query = searchInput.value.trim();
+ if (!query) {
+ statusEl.textContent = '';
+ return;
+ }
+ var n = resultsContainer.querySelectorAll('.search-result').length;
+ if (n === 0) {
+ statusEl.textContent = 'No posts match "' + query + '".';
+ } else if (n >= LIMIT) {
+ // The library stops scanning at `limit`, so the real total is unknown —
+ // saying "10 posts found" would be a guess dressed as a count.
+ statusEl.textContent = 'Showing the first ' + LIMIT + ' matches.';
+ } else {
+ statusEl.textContent = n + (n === 1 ? ' post' : ' posts') + ' found.';
+ }
}
SimpleJekyllSearch({
@@ -66,14 +176,30 @@
'{title}
' +
'' +
- '{snippet}
' +
+ '{content}
' +
'' +
'',
+ // Returning undefined leaves a field to the library's default, which is what
+ // {url} and {date} want — they are attributes and plain text, not prose.
+ templateMiddleware: function (prop, value) {
+ var words = queryWords();
+ if (prop === 'content') return excerpt(String(value == null ? '' : value), words);
+ if (prop === 'title' || prop === 'tags' || prop === 'category') {
+ return mark(String(value == null ? '' : value), words);
+ }
+ return undefined;
+ },
noResultsText: '',
- limit: 10,
+ limit: LIMIT,
fuzzy: false,
success: function () {
- searchInput.addEventListener('input', onRendered);
+ // This callback runs before the library fetches the JSON and registers its
+ // own input handler, so this listener is always the earlier of the two.
+ // Deferring by a turn lets the results render first, so the count is read
+ // from the DOM that the visitor is actually looking at.
+ searchInput.addEventListener('input', function () {
+ setTimeout(updateStatus, 0);
+ });
}
});
})();
diff --git a/search.json b/search.json
index da87d30..3b6d73a 100644
--- a/search.json
+++ b/search.json
@@ -1,6 +1,21 @@
---
layout: null
---
+{%- comment -%}
+ The client-side search index. Every post, as plain text.
+
+ `plain_text` is _plugins/search_index.rb, replacing `strip_html |
+ strip_newlines`: it decodes HTML entities (kramdown escapes a literal `>` to
+ `>`, which the result card used to print verbatim) and leaves a space at
+ block boundaries (`끝
시작
` had been indexing as one token).
+
+ There is no separate `snippet` field. It held the first 40 words, which is not
+ where a match usually is — js/search.js cuts the shown excerpt around the match
+ in `content` instead.
+
+ Every field here is searched: simple-jekyll-search walks each key and matches a
+ post when all query words appear in one of them.
+{%- endcomment -%}
[
{% for post in site.posts %}
{
@@ -9,8 +24,7 @@ layout: null
"date": "{{ post.date | date: '%Y-%m-%d' }}",
"category": "{% if post.categories %}{{ post.categories | join: ', ' }}{% endif %}",
"tags": "{% if post.tags %}{{ post.tags | join: ', ' | replace: '-', ' ' }}{% endif %}",
- "snippet": {{ post.content | strip_html | strip_newlines | truncatewords: 40 | jsonify }},
- "content": {{ post.content | strip_html | strip_newlines | jsonify }}
+ "content": {{ post.content | plain_text | jsonify }}
}{% unless forloop.last %},{% endunless %}
{% endfor %}
]
diff --git a/test/test_search_index.rb b/test/test_search_index.rb
new file mode 100644
index 0000000..5270381
--- /dev/null
+++ b/test/test_search_index.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+#
+# Unit tests for _plugins/search_index.rb.
+#
+# This text is both what the client-side search matches against and what the
+# result card shows, so a mistake here is either a term that cannot be found or
+# markup printed at the reader.
+
+require "minitest/autorun"
+require_relative "../_plugins/search_index"
+
+class TestPlainText < Minitest::Test
+ def test_unwraps_tags
+ assert_equal "본문입니다", SearchIndex.plain_text("본문입니다
")
+ end
+
+ # The reason this filter exists at all: `strip_html` left entities encoded and
+ # the search page printed "음료 > 탄산음료" to the reader.
+ def test_decodes_html_entities
+ assert_equal "음료 > 탄산음료", SearchIndex.plain_text("음료 > 탄산음료
")
+ assert_equal "a & b", SearchIndex.plain_text("a & b")
+ assert_equal %(그는 "말했다"), SearchIndex.plain_text("그는 "말했다"")
+ assert_equal "it's", SearchIndex.plain_text("it's")
+ end
+
+ # Order is load-bearing. Decoding before stripping would turn this text into a
+ # real element and the strip would delete the words the author wrote.
+ def test_entities_that_spell_a_tag_survive_as_text
+ assert_equal " 는 위험합니다",
+ SearchIndex.plain_text("<script>alert(1)</script> 는 위험합니다
")
+ end
+
+ # The second defect: adjacent blocks indexed as one unsearchable token.
+ def test_block_boundaries_become_a_space
+ assert_equal "끝 시작", SearchIndex.plain_text("끝
시작
")
+ assert_equal "하나 둘", SearchIndex.plain_text("하나 둘 ")
+ assert_equal "위 아래", SearchIndex.plain_text("위
아래")
+ assert_equal "머리 값", SearchIndex.plain_text("머리 값 ")
+ end
+
+ # …but an inline tag inside a word must not split it, or the word stops matching.
+ def test_inline_tags_do_not_split_a_word
+ assert_equal "강조된 문장", SearchIndex.plain_text("강조된 문장")
+ assert_equal "attention", SearchIndex.plain_text("attention")
+ end
+
+ def test_drops_script_and_style_content_entirely
+ assert_equal "본문", SearchIndex.plain_text("본문
")
+ assert_equal "본문", SearchIndex.plain_text("본문
")
+ end
+
+ def test_drops_comments
+ assert_equal "본문", SearchIndex.plain_text("본문
")
+ end
+
+ def test_collapses_all_whitespace_including_newlines
+ assert_equal "한 줄로", SearchIndex.plain_text("한\n\n 줄로\t")
+ end
+
+ def test_handles_nil_and_empty_input
+ assert_equal "", SearchIndex.plain_text(nil)
+ assert_equal "", SearchIndex.plain_text("")
+ assert_equal "", SearchIndex.plain_text(" \n ")
+ end
+
+ # Korean prose has no inter-word spaces to fall back on, so nothing may be
+ # dropped on the assumption that a space is nearby.
+ def test_preserves_korean_text_without_spaces
+ long = "맥락을새겨넣는법" * 20
+ assert_equal long, SearchIndex.plain_text("#{long}
")
+ end
+
+ def test_is_idempotent_on_already_plain_text
+ plain = "이미 평문입니다 & 그대로"
+ assert_equal plain, SearchIndex.plain_text(plain)
+ end
+end