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[^>]*>.*?}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{]*>}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 @@ '' + '

' + '

' + - '

{snippet}

' + + '

{content}

' + '

{tags}

' + '', + // 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

    {title}