|
53 | 53 | { |
54 | 54 | <span class="search-result-section">@r.Entry.Section</span> |
55 | 55 | } |
| 56 | + @if (!string.IsNullOrEmpty(r.Snippet)) |
| 57 | + { |
| 58 | + <span class="search-result-snippet">@r.Snippet</span> |
| 59 | + } |
56 | 60 | </span> |
57 | 61 | </button> |
58 | 62 | } |
|
158 | 162 | if (_highlightedIndex >= _results.Count) _highlightedIndex = 0; |
159 | 163 | } |
160 | 164 |
|
161 | | - /* Naive fuzzy score: substring hit boosts by big margin, then per-word |
162 | | - token overlap. Good enough for the ~100-entry indexes docs sites have. */ |
163 | 165 | private static IEnumerable<Scored> Rank(string query, IReadOnlyList<SearchEntry> entries) |
164 | 166 | { |
165 | 167 | if (string.IsNullOrWhiteSpace(query)) |
166 | 168 | { |
167 | | - return entries.Take(20).Select(e => new Scored(e, 0)); |
| 169 | + return entries.Take(20).Select(e => new Scored(e, 0, null)); |
168 | 170 | } |
169 | 171 | var q = query.Trim().ToLowerInvariant(); |
170 | 172 | var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); |
171 | 173 | return entries |
172 | | - .Select(e => new Scored(e, Score(e, q, tokens))) |
| 174 | + .Select(e => Evaluate(e, q, tokens)) |
173 | 175 | .Where(s => s.Score > 0) |
174 | 176 | .OrderByDescending(s => s.Score); |
175 | 177 | } |
176 | 178 |
|
177 | | - private static int Score(SearchEntry e, string q, string[] tokens) |
| 179 | + private static Scored Evaluate(SearchEntry e, string q, string[] tokens) |
178 | 180 | { |
179 | 181 | var title = e.Title.ToLowerInvariant(); |
180 | 182 | var desc = (e.Description ?? "").ToLowerInvariant(); |
181 | 183 | var sec = (e.Section ?? "").ToLowerInvariant(); |
| 184 | + var body = (e.Body ?? "").ToLowerInvariant(); |
182 | 185 | var score = 0; |
183 | | - // Exact substring in title: massive boost. |
184 | | - if (title.Contains(q)) score += 100; |
185 | | - if (title.StartsWith(q)) score += 50; |
186 | | - if (sec.Contains(q)) score += 20; |
187 | | - if (desc.Contains(q)) score += 10; |
188 | | - // Per-token AND — every token must appear somewhere. |
| 186 | + var bodyHit = false; |
| 187 | + |
| 188 | + if (title.Contains(q)) score += 100; |
| 189 | + if (title.StartsWith(q)) score += 50; |
| 190 | + if (sec.Contains(q)) score += 20; |
| 191 | + if (desc.Contains(q)) score += 10; |
| 192 | + if (body.Contains(q)) { score += 6; bodyHit = true; } |
| 193 | + |
189 | 194 | foreach (var t in tokens) |
190 | 195 | { |
191 | | - if (title.Contains(t)) score += 8; |
192 | | - else if (sec.Contains(t)) score += 4; |
193 | | - else if (desc.Contains(t)) score += 2; |
194 | | - else return 0; // hard reject: token missing |
| 196 | + if (title.Contains(t)) score += 8; |
| 197 | + else if (sec.Contains(t)) score += 4; |
| 198 | + else if (desc.Contains(t)) score += 2; |
| 199 | + else if (body.Contains(t)) { score += 1; bodyHit = true; } |
| 200 | + else return new Scored(e, 0, null); // hard reject: token missing everywhere |
195 | 201 | } |
196 | | - // Pages rank slightly higher than headings when otherwise-equal. |
| 202 | + |
197 | 203 | if (e.Kind == SearchEntryKind.Page) score += 2; |
198 | | - return score; |
| 204 | + |
| 205 | + // Only surface a snippet when the match came from the body (title/desc |
| 206 | + // is already shown; snippet is only useful for otherwise-hidden matches). |
| 207 | + var snippet = bodyHit && !string.IsNullOrEmpty(e.Body) |
| 208 | + ? BuildSnippet(e.Body!, tokens) |
| 209 | + : null; |
| 210 | + |
| 211 | + return new Scored(e, score, snippet); |
| 212 | + } |
| 213 | + |
| 214 | + /* Center the snippet on the first token match, ~150 chars, with ellipses |
| 215 | + when trimmed. Case-preserving. */ |
| 216 | + private static string? BuildSnippet(string body, string[] tokens) |
| 217 | + { |
| 218 | + var lower = body.ToLowerInvariant(); |
| 219 | + var hit = -1; |
| 220 | + foreach (var t in tokens) |
| 221 | + { |
| 222 | + var idx = lower.IndexOf(t, StringComparison.Ordinal); |
| 223 | + if (idx >= 0 && (hit < 0 || idx < hit)) hit = idx; |
| 224 | + } |
| 225 | + if (hit < 0) return null; |
| 226 | + |
| 227 | + const int radius = 75; |
| 228 | + var start = Math.Max(0, hit - radius); |
| 229 | + var end = Math.Min(body.Length, hit + radius); |
| 230 | + var snippet = body[start..end].Trim(); |
| 231 | + if (start > 0) snippet = "… " + snippet; |
| 232 | + if (end < body.Length) snippet += " …"; |
| 233 | + return snippet; |
199 | 234 | } |
200 | 235 |
|
201 | | - private record Scored(SearchEntry Entry, int Score); |
| 236 | + private record Scored(SearchEntry Entry, int Score, string? Snippet); |
202 | 237 |
|
203 | 238 | public void Dispose() |
204 | 239 | { |
|
0 commit comments