Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions LICENSING.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,5 @@ The home page adapts the layout and descriptive text of the earlier Redot
Docusaurus documentation site. Its reused descriptive content and the imported
Getting Started guides and media retain CC BY 3.0 unless otherwise noted. The
new Razor implementation is MIT. The imported media retains its original notices.

Search uses Lucene.NET and Lucene.NET.Analysis.Common (Apache-2.0), J2N (see its bundled notices), and Html Agility Pack (MIT). Their license texts and notices are included under `Redot-Documentation/wwwroot/licenses/` and linked from the site license page.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,11 @@ content references valid when changing versions or moving documentation.
MudBlazor providers live in `MainLayout`; nested navigation and viewer components
inherit the root render mode. Rendered Markdown stays HTML, with CSS styling and
small JavaScript helpers for code tabs and syntax highlighting.

### Documentation search

Search runs inside ASP.NET using Lucene.NET and BM25, without a crawler or external service. The header search button (Ctrl/Cmd+K) searches the selected version; `/search?q=collision&version=26.2&kind=all` is a shareable results page. Filters accept `all`, `guides`, or `classes`.

The background service indexes rendered Markdown sections and class-reference snapshots. Local indexes live under `App_Data/search/<version>/<content fingerprint>`; keep this writable directory on persistent storage to reuse indexes after restart. Fingerprints include source bytes, class revision, and a schema version. Class snapshot publication triggers a refresh; Markdown changes are picked up at application restart. Increment the search schema version when changing extraction or analysis. Index failures are logged and retain the previous searchable generation; first-time indexing displays a preparing state. Obsolete fingerprint directories are removed after the replacement reader and manifest are published. Cleanup failures are logged and retried after the next publication; temporary and unrelated directories are left untouched. Search logs section count, build duration, and disk size.

Results use literal term queries, prefix matching, and a one-edit typo fallback when no stronger matches exist. Queries are limited to 200 characters and 12 terms; results are grouped by source page. Search snippets retain the source content's existing license.
75 changes: 74 additions & 1 deletion Redot-Documentation-Tests/ClassDocumentationComponentTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Text.Json;
using Redot_Documentation.Search;
using Microsoft.AspNetCore.Components.Web;
using MudBlazor.Services;
using MudBlazor.Extensions;
using Microsoft.AspNetCore.Components;
Expand All @@ -22,6 +24,73 @@ public sealed class ClassDocumentationComponentTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"redot-component-tests-{Guid.NewGuid():N}");

[Fact]
public async Task Search_KeepsResultsAndHighlighting_AndRejectsOutOfOrderResponses()
{
var search = new ControlledSearch();
await using var services = CreateServices(search);
await using var renderer = new TestRenderer(services);
int id = await renderer.Dispatcher.InvokeAsync(() => renderer.RenderAsync(typeof(SearchPanel),
ParameterView.FromDictionary(new Dictionary<string, object?> { ["Query"] = "alpha" })));
var input = await renderer.Dispatcher.InvokeAsync(() => renderer.FindComponent<MudBlazor.MudTextField<string>>(id));
Assert.NotNull(input);
Task oldRequest = renderer.Dispatcher.InvokeAsync(() => input.ValueChanged.InvokeAsync("slow"));
try
{
await search.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
string pendingText = await renderer.Dispatcher.InvokeAsync(() => renderer.Text(id));
Assert.Contains("alpha result", pendingText);
Assert.Contains("<mark>alpha</mark>", pendingText);
string before = services.GetRequiredService<NavigationManager>().Uri;
await renderer.Dispatcher.InvokeAsync(() => input.OnKeyDown.InvokeAsync(new KeyboardEventArgs { Key = "Enter" }));
Assert.Equal(before, services.GetRequiredService<NavigationManager>().Uri);
await renderer.Dispatcher.InvokeAsync(() => input.ValueChanged.InvokeAsync("beta"));
Assert.Contains("beta result", await renderer.Dispatcher.InvokeAsync(() => renderer.Text(id)));
}
finally { search.Release.Set(); }
await oldRequest;
string finalText = await renderer.Dispatcher.InvokeAsync(() => renderer.Text(id));
Assert.Contains("beta result", finalText);
Assert.DoesNotContain("slow result", finalText);
Assert.Contains("<mark>beta</mark>", finalText);
await renderer.Dispatcher.InvokeAsync(() => input.ValueChanged.InvokeAsync(""));
Assert.DoesNotContain("beta result", await renderer.Dispatcher.InvokeAsync(() => renderer.Text(id)));
Assert.Empty(renderer.Errors);
}

[Fact]
public async Task Search_UrlParameterEchoDoesNotRepeatCompletedQuery()
{
var search = new ControlledSearch();
await using var services = CreateServices(search);
await using var renderer = new TestRenderer(services);
ParameterView Parameters(string query) => ParameterView.FromDictionary(new Dictionary<string, object?>
{ ["Query"] = query, ["Version"] = "26.1", ["Kind"] = "all", ["FullPage"] = true });
int id = await renderer.Dispatcher.InvokeAsync(() => renderer.RenderAsync(typeof(SearchPanel), Parameters("alpha")));
await renderer.Dispatcher.InvokeAsync(async () =>
{
var input = renderer.FindComponent<MudBlazor.MudTextField<string>>(id)!;
await input.ValueChanged.InvokeAsync("beta");
await renderer.UpdateAsync(id, Parameters("beta"));
});
Assert.Equal(2, search.Calls);
Assert.Contains("beta result", await renderer.Dispatcher.InvokeAsync(() => renderer.Text(id)));
Assert.Empty(renderer.Errors);
}

private sealed class ControlledSearch : IDocumentationSearch
{
public int Calls;
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public ManualResetEventSlim Release { get; } = new(false);
public SearchResponse Search(string version, string query, string kind = "all", int limit = 30)
{
Interlocked.Increment(ref Calls);
if (query == "slow") { Started.TrySetResult(); Release.Wait(TimeSpan.FromSeconds(10)); }
return new(true, [new(query + " result", query, "/en/26.1/" + query, "guides", "alpha beta slow")]);
}
}

[Fact]
public async Task NavMenu_SelectsConfiguredVersionThroughMudSelect()
{
Expand Down Expand Up @@ -181,7 +250,7 @@ ClassDocumentationSnapshot Snapshot(DocumentationVersion version, string descrip
Assert.Empty(renderer.Errors);
}

private ServiceProvider CreateServices()
private ServiceProvider CreateServices(IDocumentationSearch? search = null)
{
DocumentationVersion[] versions =
[
Expand All @@ -194,6 +263,7 @@ private ServiceProvider CreateServices()
var manager = new VersionManagerService(new TestEnvironment(_root));
manager.LoadContent();
return new ServiceCollection().AddLogging()
.AddSingleton<IDocumentationSearch>(search ?? new ControlledSearch())
.AddMudServices(options => options.PopoverOptions.CheckForPopoverProvider = false)
.AddSingleton<DocRendererService>(new DocRendererService(new TestEnvironment(_root)))
.AddSingleton(manager)
Expand All @@ -220,6 +290,8 @@ public async Task<int> RenderAsync(Type type, ParameterView parameters)
return id;
}

public Task UpdateAsync(int id, ParameterView parameters) => RenderRootComponentAsync(id, parameters);

public void Remove(int id) => RemoveRootComponent(id);

public IEnumerable<string> Hrefs(int id)
Expand Down Expand Up @@ -270,6 +342,7 @@ protected override Task UpdateDisplayAsync(in RenderBatch batch)
private sealed class TestNavigationManager : NavigationManager
{
public TestNavigationManager() => Initialize("http://localhost/", "http://localhost/en/26.1/Classes");
protected override void NavigateToCore(string uri, NavigationOptions options) => NavigateToCore(uri, options.ForceLoad);
protected override void NavigateToCore(string uri, bool forceLoad)
{
Uri = ToAbsoluteUri(uri).AbsoluteUri;
Expand Down
136 changes: 136 additions & 0 deletions Redot-Documentation-Tests/DocumentationSearchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging;
using Redot_Documentation.Search;
using Redot_Documentation.Services;
using Redot_Documentation.Versioning;
using Redot_Documentation.ClassDocumentation;

namespace Redot_Documentation_Tests;

public sealed class DocumentationSearchTests
{
[Theory]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void CollectDistinctPages_SearchesBeyondFirstThousandSections(int requestedPages)
{
using var directory = new Lucene.Net.Store.RAMDirectory();
using var analyzer = new Lucene.Net.Analysis.Core.WhitespaceAnalyzer(Lucene.Net.Util.LuceneVersion.LUCENE_48);
using (var writer = new Lucene.Net.Index.IndexWriter(directory,
new Lucene.Net.Index.IndexWriterConfig(Lucene.Net.Util.LuceneVersion.LUCENE_48, analyzer)))
{
for (int i = 0; i < 1100; i++)
DocumentationSearchService.Add(writer, new("Dense", "Section", $"/dense#{i}", "/dense", "guides", "needle"));
DocumentationSearchService.Add(writer, new("Second", "Section", "/second", "/second", "guides", "needle"));
DocumentationSearchService.Add(writer, new("Third", "Section", "/third", "/third", "guides", "needle"));
writer.Commit();
}
using var reader = Lucene.Net.Index.DirectoryReader.Open(directory);
var searcher = new Lucene.Net.Search.IndexSearcher(reader);
var query = new Lucene.Net.Search.TermQuery(new Lucene.Net.Index.Term("bodyTerms", "needle"));
var hits = DocumentationSearchService.CollectDistinctPages(searcher, query, requestedPages);
Assert.Equal(new[] { "/dense", "/second", "/third" }.Take(requestedPages), hits.Select(d => d.Get("page")));
Assert.Equal("/dense#0", hits[0].Get("url"));
}

[Fact]
public async Task Index_SearchesBodyAndMembers_IsolatesVersions_AndRefreshesSnapshots()
{
string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
try
{
foreach (string version in new[] { "stable", "latest" })
{
Directory.CreateDirectory(Path.Combine(root, "docs", version));
await File.WriteAllTextAsync(Path.Combine(root, "docs", version, "player.md"),
"# Coding the player\n\n## Physics\n\n" + (version == "stable" ? "Detect collisions with walls." : "Future physics changes."));
}
await File.WriteAllTextAsync(Path.Combine(root, "docs", "Versions.json"), JsonSerializer.Serialize(new[] {
new DocumentationVersion { Slug="stable",FriendlyName="Stable",BranchName="stable",IsLatestStable=true },
new DocumentationVersion { Slug="latest",FriendlyName="Latest",BranchName="master",IsNextPrerelease=true }
}));
var env = new TestEnvironment(root);
var versions = new VersionManagerService(env); versions.LoadContent();
var catalog = new ClassDocumentationCatalog();
var logger = new TestLogger();
using var service = new DocumentationSearchService(versions, catalog, env, logger);
await service.StartAsync(default);
await Wait(() => service.Search("latest", "physics").Available);
string cache = Path.Combine(root, "App_Data", "search", "stable");
string manifest = Path.Combine(cache, "current.txt");
await Wait(() => File.Exists(manifest));
string initialIndex = Path.Combine(cache, File.ReadAllText(manifest));
string temporary = Path.Combine(cache, new string('A', 64) + ".building-test");
string unrelated = Path.Combine(cache, "notes");
Directory.CreateDirectory(temporary);
Directory.CreateDirectory(unrelated);
var hits = service.Search("stable", "detect collisions").Hits;
Assert.Single(hits);
Assert.EndsWith("player#physics", hits[0].Url);
Assert.Contains("collisions", hits[0].Snippet);
Assert.Empty(service.Search("latest", "collisions").Hits);
Assert.Empty(service.Search("stable", "collisions", "classes").Hits);
Assert.Single(service.Search("stable", "colisions").Hits);
catalog.Publish(new(versions.LatestStableVersion, "revision1", DateTimeOffset.UtcNow,
new Dictionary<string, ClassDocumentationEntry> { ["CharacterBody3D"] = new() { Name = "CharacterBody3D", Description = "Sliding collision motion", Methods = [new() { Name = "move_and_slide", Description = "Move while sliding along walls." }] } }));
await Wait(() => service.Search("stable", "character body", "classes").Hits.Count == 1);
await Wait(() => !Directory.Exists(initialIndex));
string firstClassIndex = Path.Combine(cache, File.ReadAllText(manifest));
Assert.True(Directory.Exists(firstClassIndex));
Assert.EndsWith("#method-move-and-slide", service.Search("stable", "move_and_slide", "classes").Hits[0].Url);
catalog.Publish(new(versions.LatestStableVersion, "revision2", DateTimeOffset.UtcNow,
new Dictionary<string, ClassDocumentationEntry> { ["NewClass"] = new() { Name = "NewClass", Description = "Replacement snapshot" } }));
await Wait(() => service.Search("stable", "replacement", "classes").Hits.Count == 1);
await Wait(() => !Directory.Exists(firstClassIndex));
string activeIndex = Path.Combine(cache, File.ReadAllText(manifest));
Assert.True(Directory.Exists(activeIndex));
Assert.Single(Directory.GetDirectories(cache), p => Path.GetFileName(p).Length == 64);
Assert.True(Directory.Exists(temporary));
Assert.True(Directory.Exists(unrelated));
Assert.Single(service.Search("latest", "physics").Hits);
Assert.Empty(service.Search("stable", "character body", "classes").Hits);
env.ContentRootPath = Path.Combine(root, "missing-root");
catalog.Publish(new(versions.LatestStableVersion, "revision3", DateTimeOffset.UtcNow,
new Dictionary<string, ClassDocumentationEntry>()));
await Wait(() => logger.Errors > 0);
Assert.True(Directory.Exists(activeIndex));
Assert.Equal(activeIndex, Path.Combine(cache, File.ReadAllText(manifest)));
Assert.Single(service.Search("stable", "replacement", "classes").Hits);
env.ContentRootPath = root;
catalog.Publish(new(versions.LatestStableVersion, "revision2", DateTimeOffset.UtcNow,
new Dictionary<string, ClassDocumentationEntry> { ["NewClass"] = new() { Name = "NewClass", Description = "Replacement snapshot" } }));
await service.StopAsync(default);
using var cached = new DocumentationSearchService(versions, catalog, env, NullLogger<DocumentationSearchService>.Instance);
await cached.StartAsync(default);
await Wait(() => cached.Search("stable", "replacement").Hits.Count == 1);
await cached.StopAsync(default);
}
finally { Directory.Delete(root, true); }
}
private static async Task Wait(Func<bool> ready)
{
for (int i = 0; i < 200; i++) { if (ready()) return; await Task.Delay(50); }
Assert.True(ready(), "Search index did not become ready");
}
private sealed class TestLogger : ILogger<DocumentationSearchService>
{
public int Errors;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel level) => true;
public void Log<TState>(LogLevel level, EventId id, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{ if (level == LogLevel.Error) Interlocked.Increment(ref Errors); }
}
private sealed class TestEnvironment(string root) : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "Tests";
public string EnvironmentName { get; set; } = "Development";
public string ContentRootPath { get; set; } = root;
public string WebRootPath { get; set; } = root;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider();
}
}
41 changes: 41 additions & 0 deletions Redot-Documentation/Components/Layout/HeaderSearch.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@implements IAsyncDisposable
@inject IDialogService Dialogs
@inject NavigationManager Navigation
@inject Redot_Documentation.Services.VersionManagerService Versions
@inject IJSRuntime JS
<MudButton id="documentation-search-trigger" Class="header-search" OnClick="Open" Variant="Variant.Outlined" aria-label="Search documentation">
<MudIcon Icon="@Icons.Material.Filled.Search" /><span class="search-placeholder">Search documentation…</span><kbd>Ctrl / ⌘ K</kbd>
</MudButton>
@code {
private IJSObjectReference? _module;
private DotNetObjectReference<HeaderSearch>? _reference;
private bool _open;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/search.js");
_reference = DotNetObjectReference.Create(this);
await _module.InvokeVoidAsync("register", _reference);
}
[JSInvokable] public async Task Open()
{
if (_open) return;
_open = true;
try
{
string[] parts = Navigation.ToBaseRelativePath(Navigation.Uri).Split('/');
string version = parts.Length > 1 && string.Equals(parts[0], "en", StringComparison.OrdinalIgnoreCase) && Versions.TryGetVersionProvider(parts[1],out _) ? parts[1] : Versions.LatestStableVersion.Slug;
bool mobile = _module is not null && await _module.InvokeAsync<bool>("isMobile");
var dialog = await Dialogs.ShowAsync<SearchDialog>("Search",new DialogParameters { ["Version"] = version },new DialogOptions { FullWidth = true, MaxWidth = MaxWidth.Medium, FullScreen = mobile, CloseButton = true, CloseOnEscapeKey = true });
await dialog.Result;
if (_module is not null) await _module.InvokeVoidAsync("restoreFocus");
}
finally { _open = false; }
}
public async ValueTask DisposeAsync()
{
try { if (_module is not null) { await _module.InvokeVoidAsync("unregister"); await _module.DisposeAsync(); } }
catch (JSDisconnectedException) { }
_reference?.Dispose();
}
}
Loading
Loading