diff --git a/src/ShellDocs.Components/Chrome/SearchDialog.razor b/src/ShellDocs.Components/Chrome/SearchDialog.razor index 4d499fd..be9cce8 100644 --- a/src/ShellDocs.Components/Chrome/SearchDialog.razor +++ b/src/ShellDocs.Components/Chrome/SearchDialog.razor @@ -53,6 +53,10 @@ { @r.Entry.Section } + @if (!string.IsNullOrEmpty(r.Snippet)) + { + @r.Snippet + } } @@ -158,47 +162,78 @@ if (_highlightedIndex >= _results.Count) _highlightedIndex = 0; } - /* Naive fuzzy score: substring hit boosts by big margin, then per-word - token overlap. Good enough for the ~100-entry indexes docs sites have. */ private static IEnumerable Rank(string query, IReadOnlyList entries) { if (string.IsNullOrWhiteSpace(query)) { - return entries.Take(20).Select(e => new Scored(e, 0)); + return entries.Take(20).Select(e => new Scored(e, 0, null)); } var q = query.Trim().ToLowerInvariant(); var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); return entries - .Select(e => new Scored(e, Score(e, q, tokens))) + .Select(e => Evaluate(e, q, tokens)) .Where(s => s.Score > 0) .OrderByDescending(s => s.Score); } - private static int Score(SearchEntry e, string q, string[] tokens) + private static Scored Evaluate(SearchEntry e, string q, string[] tokens) { var title = e.Title.ToLowerInvariant(); var desc = (e.Description ?? "").ToLowerInvariant(); var sec = (e.Section ?? "").ToLowerInvariant(); + var body = (e.Body ?? "").ToLowerInvariant(); var score = 0; - // Exact substring in title: massive boost. - if (title.Contains(q)) score += 100; - if (title.StartsWith(q)) score += 50; - if (sec.Contains(q)) score += 20; - if (desc.Contains(q)) score += 10; - // Per-token AND — every token must appear somewhere. + var bodyHit = false; + + if (title.Contains(q)) score += 100; + if (title.StartsWith(q)) score += 50; + if (sec.Contains(q)) score += 20; + if (desc.Contains(q)) score += 10; + if (body.Contains(q)) { score += 6; bodyHit = true; } + foreach (var t in tokens) { - if (title.Contains(t)) score += 8; - else if (sec.Contains(t)) score += 4; - else if (desc.Contains(t)) score += 2; - else return 0; // hard reject: token missing + if (title.Contains(t)) score += 8; + else if (sec.Contains(t)) score += 4; + else if (desc.Contains(t)) score += 2; + else if (body.Contains(t)) { score += 1; bodyHit = true; } + else return new Scored(e, 0, null); // hard reject: token missing everywhere } - // Pages rank slightly higher than headings when otherwise-equal. + if (e.Kind == SearchEntryKind.Page) score += 2; - return score; + + // Only surface a snippet when the match came from the body (title/desc + // is already shown; snippet is only useful for otherwise-hidden matches). + var snippet = bodyHit && !string.IsNullOrEmpty(e.Body) + ? BuildSnippet(e.Body!, tokens) + : null; + + return new Scored(e, score, snippet); + } + + /* Center the snippet on the first token match, ~150 chars, with ellipses + when trimmed. Case-preserving. */ + private static string? BuildSnippet(string body, string[] tokens) + { + var lower = body.ToLowerInvariant(); + var hit = -1; + foreach (var t in tokens) + { + var idx = lower.IndexOf(t, StringComparison.Ordinal); + if (idx >= 0 && (hit < 0 || idx < hit)) hit = idx; + } + if (hit < 0) return null; + + const int radius = 75; + var start = Math.Max(0, hit - radius); + var end = Math.Min(body.Length, hit + radius); + var snippet = body[start..end].Trim(); + if (start > 0) snippet = "… " + snippet; + if (end < body.Length) snippet += " …"; + return snippet; } - private record Scored(SearchEntry Entry, int Score); + private record Scored(SearchEntry Entry, int Score, string? Snippet); public void Dispose() { diff --git a/src/ShellDocs.Components/Chrome/SearchDialog.razor.css b/src/ShellDocs.Components/Chrome/SearchDialog.razor.css index 5832fa3..b977720 100644 --- a/src/ShellDocs.Components/Chrome/SearchDialog.razor.css +++ b/src/ShellDocs.Components/Chrome/SearchDialog.razor.css @@ -130,6 +130,15 @@ text-overflow: ellipsis; white-space: nowrap; } +.search-result-snippet { + font-size: 0.75rem; + color: var(--muted-foreground); + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} .search-footer { display: flex; diff --git a/src/ShellDocs.Core/MarkdownPlainText.cs b/src/ShellDocs.Core/MarkdownPlainText.cs new file mode 100644 index 0000000..d06138d --- /dev/null +++ b/src/ShellDocs.Core/MarkdownPlainText.cs @@ -0,0 +1,41 @@ +using System.Text.RegularExpressions; + +namespace ShellDocs.Core; + +/* Extracts plain text from markdown for search-body indexing. Strips YAML + frontmatter, fenced code blocks, razor component tags, inline HTML, and + the surface markdown syntax (headings, emphasis, links, images). Preserves + the actual prose so token matching finds body-only hits. */ +public static class MarkdownPlainText +{ + private static readonly Regex Frontmatter = new(@"^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n", RegexOptions.Compiled); + private static readonly Regex FencedBlock = new(@"^```[\s\S]*?^```", RegexOptions.Multiline | RegexOptions.Compiled); + private static readonly Regex HtmlTag = new(@"<[^>]+>", RegexOptions.Compiled); + private static readonly Regex Image = new(@"!\[([^\]]*)\]\([^\)]*\)", RegexOptions.Compiled); + private static readonly Regex Link = new(@"\[([^\]]+)\]\([^\)]*\)", RegexOptions.Compiled); + private static readonly Regex InlineCode = new(@"`([^`]+)`", RegexOptions.Compiled); + private static readonly Regex Emphasis = new(@"(\*\*|__|\*|_)(.+?)\1", RegexOptions.Compiled); + private static readonly Regex HeadingHash = new(@"^#{1,6}\s+", RegexOptions.Multiline | RegexOptions.Compiled); + private static readonly Regex ListMarker = new(@"^\s*[-*+]\s+|^\s*\d+\.\s+", RegexOptions.Multiline | RegexOptions.Compiled); + private static readonly Regex Blockquote = new(@"^>\s?", RegexOptions.Multiline | RegexOptions.Compiled); + private static readonly Regex WhitespaceRun = new(@"\s+", RegexOptions.Compiled); + + public static string Extract(string markdown, int maxLength = 8000) + { + if (string.IsNullOrEmpty(markdown)) return ""; + + var text = Frontmatter.Replace(markdown, ""); + text = FencedBlock.Replace(text, " "); + text = HtmlTag.Replace(text, " "); + text = Image.Replace(text, "$1"); + text = Link.Replace(text, "$1"); + text = InlineCode.Replace(text, "$1"); + text = Emphasis.Replace(text, "$2"); + text = HeadingHash.Replace(text, ""); + text = ListMarker.Replace(text, ""); + text = Blockquote.Replace(text, ""); + text = WhitespaceRun.Replace(text, " ").Trim(); + + return text.Length > maxLength ? text[..maxLength] : text; + } +} diff --git a/src/ShellDocs.Core/SearchIndex.cs b/src/ShellDocs.Core/SearchIndex.cs index 30bff4b..6083140 100644 --- a/src/ShellDocs.Core/SearchIndex.cs +++ b/src/ShellDocs.Core/SearchIndex.cs @@ -4,8 +4,8 @@ namespace ShellDocs.Core; /* An in-memory search index built from the navigation graph. Each entry represents one searchable thing — a page, or a heading within a page. - Client-side fuzzy match runs against Title + Description + Section over the - wire; body-text indexing lands with the search-index.json build step. */ + Page entries carry a trimmed plain-text body so client-side match can + find hits that aren't in the title/description surface. */ public sealed class SearchIndex { public IReadOnlyList Entries { get; } @@ -28,7 +28,8 @@ private static void Walk(NavigationNode node, string? section, List Title: node.Title, Description: node.Description, Section: section, - Kind: SearchEntryKind.Page)); + Kind: SearchEntryKind.Page, + Body: ExtractBodyFromFile(node.Path))); // Prefer headings already extracted at render time; otherwise pull // them from the source markdown ourselves so the index isn't blank @@ -75,6 +76,12 @@ private static IReadOnlyList ExtractHeadingsFromFile(string? path) return list; } + private static string? ExtractBodyFromFile(string? path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; + return MarkdownPlainText.Extract(File.ReadAllText(path)); + } + private static string Slugify(string text) { var lowered = text.ToLowerInvariant(); @@ -97,6 +104,7 @@ public record SearchEntry( string Title, string? Description, string? Section, - SearchEntryKind Kind); + SearchEntryKind Kind, + string? Body = null); public enum SearchEntryKind { Page, Heading } diff --git a/tests/ShellDocs.Tests/MarkdownPlainTextTests.cs b/tests/ShellDocs.Tests/MarkdownPlainTextTests.cs new file mode 100644 index 0000000..dd2a5b3 --- /dev/null +++ b/tests/ShellDocs.Tests/MarkdownPlainTextTests.cs @@ -0,0 +1,80 @@ +using ShellDocs.Core; +using Xunit; + +namespace ShellDocs.Tests; + +public class MarkdownPlainTextTests +{ + [Fact] + public void Extract_StripsFrontmatter() + { + var text = MarkdownPlainText.Extract("---\ntitle: Foo\n---\nHello world"); + Assert.Equal("Hello world", text); + } + + [Fact] + public void Extract_StripsFencedCodeBlocks() + { + var md = "Prose before.\n\n```csharp\nvar x = 1;\n```\n\nProse after."; + var text = MarkdownPlainText.Extract(md); + Assert.Contains("Prose before", text); + Assert.Contains("Prose after", text); + Assert.DoesNotContain("var x = 1", text); + } + + [Fact] + public void Extract_StripsRazorComponentTags() + { + var text = MarkdownPlainText.Extract("Before body after."); + Assert.DoesNotContain(" e.Kind == SearchEntryKind.Page); Assert.Equal("Get started with ShellDocs", page.Description); } + + [Fact] + public void FromGraph_PageEntryCarriesExtractedBody() + { + WritePage("intro.md", "Introduction", + bodyHeadings: "## Setup\n\nRun the CLI to scaffold a new project."); + var graph = NavigationGraphBuilder.Build(_root); + var index = SearchIndex.FromGraph(graph); + var page = index.Entries.First(e => e.Kind == SearchEntryKind.Page); + Assert.NotNull(page.Body); + Assert.Contains("Run the CLI", page.Body); + } + + [Fact] + public void FromGraph_HeadingEntriesHaveNullBody() + { + WritePage("intro.md", "Introduction", bodyHeadings: "## Setup\n\nBody prose."); + var graph = NavigationGraphBuilder.Build(_root); + var index = SearchIndex.FromGraph(graph); + var heading = index.Entries.First(e => e.Kind == SearchEntryKind.Heading); + Assert.Null(heading.Body); + } }