Skip to content

Commit c303425

Browse files
authored
Merge pull request #18 from shellui-dev/feat/search-body-indexing
feat: search body-text indexing with snippet extraction
2 parents 1a3709c + feaba9b commit c303425

6 files changed

Lines changed: 217 additions & 22 deletions

File tree

src/ShellDocs.Components/Chrome/SearchDialog.razor

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@
5353
{
5454
<span class="search-result-section">@r.Entry.Section</span>
5555
}
56+
@if (!string.IsNullOrEmpty(r.Snippet))
57+
{
58+
<span class="search-result-snippet">@r.Snippet</span>
59+
}
5660
</span>
5761
</button>
5862
}
@@ -158,47 +162,78 @@
158162
if (_highlightedIndex >= _results.Count) _highlightedIndex = 0;
159163
}
160164

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. */
163165
private static IEnumerable<Scored> Rank(string query, IReadOnlyList<SearchEntry> entries)
164166
{
165167
if (string.IsNullOrWhiteSpace(query))
166168
{
167-
return entries.Take(20).Select(e => new Scored(e, 0));
169+
return entries.Take(20).Select(e => new Scored(e, 0, null));
168170
}
169171
var q = query.Trim().ToLowerInvariant();
170172
var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
171173
return entries
172-
.Select(e => new Scored(e, Score(e, q, tokens)))
174+
.Select(e => Evaluate(e, q, tokens))
173175
.Where(s => s.Score > 0)
174176
.OrderByDescending(s => s.Score);
175177
}
176178

177-
private static int Score(SearchEntry e, string q, string[] tokens)
179+
private static Scored Evaluate(SearchEntry e, string q, string[] tokens)
178180
{
179181
var title = e.Title.ToLowerInvariant();
180182
var desc = (e.Description ?? "").ToLowerInvariant();
181183
var sec = (e.Section ?? "").ToLowerInvariant();
184+
var body = (e.Body ?? "").ToLowerInvariant();
182185
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+
189194
foreach (var t in tokens)
190195
{
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
195201
}
196-
// Pages rank slightly higher than headings when otherwise-equal.
202+
197203
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;
199234
}
200235

201-
private record Scored(SearchEntry Entry, int Score);
236+
private record Scored(SearchEntry Entry, int Score, string? Snippet);
202237

203238
public void Dispose()
204239
{

src/ShellDocs.Components/Chrome/SearchDialog.razor.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@
130130
text-overflow: ellipsis;
131131
white-space: nowrap;
132132
}
133+
.search-result-snippet {
134+
font-size: 0.75rem;
135+
color: var(--muted-foreground);
136+
line-height: 1.45;
137+
display: -webkit-box;
138+
-webkit-line-clamp: 2;
139+
-webkit-box-orient: vertical;
140+
overflow: hidden;
141+
}
133142

134143
.search-footer {
135144
display: flex;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Text.RegularExpressions;
2+
3+
namespace ShellDocs.Core;
4+
5+
/* Extracts plain text from markdown for search-body indexing. Strips YAML
6+
frontmatter, fenced code blocks, razor component tags, inline HTML, and
7+
the surface markdown syntax (headings, emphasis, links, images). Preserves
8+
the actual prose so token matching finds body-only hits. */
9+
public static class MarkdownPlainText
10+
{
11+
private static readonly Regex Frontmatter = new(@"^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n", RegexOptions.Compiled);
12+
private static readonly Regex FencedBlock = new(@"^```[\s\S]*?^```", RegexOptions.Multiline | RegexOptions.Compiled);
13+
private static readonly Regex HtmlTag = new(@"<[^>]+>", RegexOptions.Compiled);
14+
private static readonly Regex Image = new(@"!\[([^\]]*)\]\([^\)]*\)", RegexOptions.Compiled);
15+
private static readonly Regex Link = new(@"\[([^\]]+)\]\([^\)]*\)", RegexOptions.Compiled);
16+
private static readonly Regex InlineCode = new(@"`([^`]+)`", RegexOptions.Compiled);
17+
private static readonly Regex Emphasis = new(@"(\*\*|__|\*|_)(.+?)\1", RegexOptions.Compiled);
18+
private static readonly Regex HeadingHash = new(@"^#{1,6}\s+", RegexOptions.Multiline | RegexOptions.Compiled);
19+
private static readonly Regex ListMarker = new(@"^\s*[-*+]\s+|^\s*\d+\.\s+", RegexOptions.Multiline | RegexOptions.Compiled);
20+
private static readonly Regex Blockquote = new(@"^>\s?", RegexOptions.Multiline | RegexOptions.Compiled);
21+
private static readonly Regex WhitespaceRun = new(@"\s+", RegexOptions.Compiled);
22+
23+
public static string Extract(string markdown, int maxLength = 8000)
24+
{
25+
if (string.IsNullOrEmpty(markdown)) return "";
26+
27+
var text = Frontmatter.Replace(markdown, "");
28+
text = FencedBlock.Replace(text, " ");
29+
text = HtmlTag.Replace(text, " ");
30+
text = Image.Replace(text, "$1");
31+
text = Link.Replace(text, "$1");
32+
text = InlineCode.Replace(text, "$1");
33+
text = Emphasis.Replace(text, "$2");
34+
text = HeadingHash.Replace(text, "");
35+
text = ListMarker.Replace(text, "");
36+
text = Blockquote.Replace(text, "");
37+
text = WhitespaceRun.Replace(text, " ").Trim();
38+
39+
return text.Length > maxLength ? text[..maxLength] : text;
40+
}
41+
}

src/ShellDocs.Core/SearchIndex.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ namespace ShellDocs.Core;
44

55
/* An in-memory search index built from the navigation graph. Each entry
66
represents one searchable thing — a page, or a heading within a page.
7-
Client-side fuzzy match runs against Title + Description + Section over the
8-
wire; body-text indexing lands with the search-index.json build step. */
7+
Page entries carry a trimmed plain-text body so client-side match can
8+
find hits that aren't in the title/description surface. */
99
public sealed class SearchIndex
1010
{
1111
public IReadOnlyList<SearchEntry> Entries { get; }
@@ -28,7 +28,8 @@ private static void Walk(NavigationNode node, string? section, List<SearchEntry>
2828
Title: node.Title,
2929
Description: node.Description,
3030
Section: section,
31-
Kind: SearchEntryKind.Page));
31+
Kind: SearchEntryKind.Page,
32+
Body: ExtractBodyFromFile(node.Path)));
3233

3334
// Prefer headings already extracted at render time; otherwise pull
3435
// them from the source markdown ourselves so the index isn't blank
@@ -75,6 +76,12 @@ private static IReadOnlyList<Heading> ExtractHeadingsFromFile(string? path)
7576
return list;
7677
}
7778

79+
private static string? ExtractBodyFromFile(string? path)
80+
{
81+
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
82+
return MarkdownPlainText.Extract(File.ReadAllText(path));
83+
}
84+
7885
private static string Slugify(string text)
7986
{
8087
var lowered = text.ToLowerInvariant();
@@ -97,6 +104,7 @@ public record SearchEntry(
97104
string Title,
98105
string? Description,
99106
string? Section,
100-
SearchEntryKind Kind);
107+
SearchEntryKind Kind,
108+
string? Body = null);
101109

102110
public enum SearchEntryKind { Page, Heading }
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using ShellDocs.Core;
2+
using Xunit;
3+
4+
namespace ShellDocs.Tests;
5+
6+
public class MarkdownPlainTextTests
7+
{
8+
[Fact]
9+
public void Extract_StripsFrontmatter()
10+
{
11+
var text = MarkdownPlainText.Extract("---\ntitle: Foo\n---\nHello world");
12+
Assert.Equal("Hello world", text);
13+
}
14+
15+
[Fact]
16+
public void Extract_StripsFencedCodeBlocks()
17+
{
18+
var md = "Prose before.\n\n```csharp\nvar x = 1;\n```\n\nProse after.";
19+
var text = MarkdownPlainText.Extract(md);
20+
Assert.Contains("Prose before", text);
21+
Assert.Contains("Prose after", text);
22+
Assert.DoesNotContain("var x = 1", text);
23+
}
24+
25+
[Fact]
26+
public void Extract_StripsRazorComponentTags()
27+
{
28+
var text = MarkdownPlainText.Extract("Before <Callout Title=\"x\">body</Callout> after.");
29+
Assert.DoesNotContain("<Callout", text);
30+
Assert.Contains("body", text);
31+
Assert.Contains("Before", text);
32+
Assert.Contains("after", text);
33+
}
34+
35+
[Fact]
36+
public void Extract_UnwrapsLinksAndImages()
37+
{
38+
var text = MarkdownPlainText.Extract("See [our docs](https://x.com) and ![alt](/img.png).");
39+
Assert.Contains("our docs", text);
40+
Assert.Contains("alt", text);
41+
Assert.DoesNotContain("https://", text);
42+
Assert.DoesNotContain("img.png", text);
43+
}
44+
45+
[Fact]
46+
public void Extract_UnwrapsEmphasisAndInlineCode()
47+
{
48+
var text = MarkdownPlainText.Extract("This is **bold**, *italic*, and `code`.");
49+
Assert.Contains("bold", text);
50+
Assert.Contains("italic", text);
51+
Assert.Contains("code", text);
52+
Assert.DoesNotContain("**", text);
53+
Assert.DoesNotContain("`", text);
54+
}
55+
56+
[Fact]
57+
public void Extract_StripsHeadingHashesButKeepsText()
58+
{
59+
var text = MarkdownPlainText.Extract("## Setup\n\n### Install\n\nRun the CLI.");
60+
Assert.Contains("Setup", text);
61+
Assert.Contains("Install", text);
62+
Assert.Contains("Run the CLI", text);
63+
Assert.DoesNotContain("##", text);
64+
}
65+
66+
[Fact]
67+
public void Extract_TrimsAtMaxLength()
68+
{
69+
var long_ = new string('a', 10_000);
70+
var text = MarkdownPlainText.Extract(long_, maxLength: 500);
71+
Assert.Equal(500, text.Length);
72+
}
73+
74+
[Fact]
75+
public void Extract_HandlesEmptyInput()
76+
{
77+
Assert.Equal("", MarkdownPlainText.Extract(""));
78+
Assert.Equal("", MarkdownPlainText.Extract(null!));
79+
}
80+
}

tests/ShellDocs.Tests/SearchIndexTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,26 @@ public void FromGraph_PageEntryCarriesDescription()
7575
var page = index.Entries.First(e => e.Kind == SearchEntryKind.Page);
7676
Assert.Equal("Get started with ShellDocs", page.Description);
7777
}
78+
79+
[Fact]
80+
public void FromGraph_PageEntryCarriesExtractedBody()
81+
{
82+
WritePage("intro.md", "Introduction",
83+
bodyHeadings: "## Setup\n\nRun the CLI to scaffold a new project.");
84+
var graph = NavigationGraphBuilder.Build(_root);
85+
var index = SearchIndex.FromGraph(graph);
86+
var page = index.Entries.First(e => e.Kind == SearchEntryKind.Page);
87+
Assert.NotNull(page.Body);
88+
Assert.Contains("Run the CLI", page.Body);
89+
}
90+
91+
[Fact]
92+
public void FromGraph_HeadingEntriesHaveNullBody()
93+
{
94+
WritePage("intro.md", "Introduction", bodyHeadings: "## Setup\n\nBody prose.");
95+
var graph = NavigationGraphBuilder.Build(_root);
96+
var index = SearchIndex.FromGraph(graph);
97+
var heading = index.Entries.First(e => e.Kind == SearchEntryKind.Heading);
98+
Assert.Null(heading.Body);
99+
}
78100
}

0 commit comments

Comments
 (0)