diff --git a/LICENSING.md b/LICENSING.md
index b2e5705..6f7fb32 100644
--- a/LICENSING.md
+++ b/LICENSING.md
@@ -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.
diff --git a/README.md b/README.md
index 0700edb..eb52212 100644
--- a/README.md
+++ b/README.md
@@ -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//`; 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.
diff --git a/Redot-Documentation-Tests/ClassDocumentationComponentTests.cs b/Redot-Documentation-Tests/ClassDocumentationComponentTests.cs
index 7e04415..0098f66 100644
--- a/Redot-Documentation-Tests/ClassDocumentationComponentTests.cs
+++ b/Redot-Documentation-Tests/ClassDocumentationComponentTests.cs
@@ -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;
@@ -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 { ["Query"] = "alpha" })));
+ var input = await renderer.Dispatcher.InvokeAsync(() => renderer.FindComponent>(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("alpha", pendingText);
+ string before = services.GetRequiredService().Uri;
+ await renderer.Dispatcher.InvokeAsync(() => input.OnKeyDown.InvokeAsync(new KeyboardEventArgs { Key = "Enter" }));
+ Assert.Equal(before, services.GetRequiredService().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("beta", 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
+ { ["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>(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()
{
@@ -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 =
[
@@ -194,6 +263,7 @@ private ServiceProvider CreateServices()
var manager = new VersionManagerService(new TestEnvironment(_root));
manager.LoadContent();
return new ServiceCollection().AddLogging()
+ .AddSingleton(search ?? new ControlledSearch())
.AddMudServices(options => options.PopoverOptions.CheckForPopoverProvider = false)
.AddSingleton(new DocRendererService(new TestEnvironment(_root)))
.AddSingleton(manager)
@@ -220,6 +290,8 @@ public async Task 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 Hrefs(int id)
@@ -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;
diff --git a/Redot-Documentation-Tests/DocumentationSearchTests.cs b/Redot-Documentation-Tests/DocumentationSearchTests.cs
new file mode 100644
index 0000000..1764f09
--- /dev/null
+++ b/Redot-Documentation-Tests/DocumentationSearchTests.cs
@@ -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 { ["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 { ["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()));
+ 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 { ["NewClass"] = new() { Name = "NewClass", Description = "Replacement snapshot" } }));
+ await service.StopAsync(default);
+ using var cached = new DocumentationSearchService(versions, catalog, env, NullLogger.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 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
+ {
+ public int Errors;
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+ public bool IsEnabled(LogLevel level) => true;
+ public void Log(LogLevel level, EventId id, TState state, Exception? exception, Func 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();
+ }
+}
diff --git a/Redot-Documentation/Components/Layout/HeaderSearch.razor b/Redot-Documentation/Components/Layout/HeaderSearch.razor
new file mode 100644
index 0000000..84b9845
--- /dev/null
+++ b/Redot-Documentation/Components/Layout/HeaderSearch.razor
@@ -0,0 +1,41 @@
+@implements IAsyncDisposable
+@inject IDialogService Dialogs
+@inject NavigationManager Navigation
+@inject Redot_Documentation.Services.VersionManagerService Versions
+@inject IJSRuntime JS
+
+@code {
+ private IJSObjectReference? _module;
+ private DotNetObjectReference? _reference;
+ private bool _open;
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender) return;
+ _module = await JS.InvokeAsync("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("isMobile");
+ var dialog = await Dialogs.ShowAsync("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();
+ }
+}
diff --git a/Redot-Documentation/Components/Layout/MainLayout.razor b/Redot-Documentation/Components/Layout/MainLayout.razor
index 0b6168e..feb8791 100644
--- a/Redot-Documentation/Components/Layout/MainLayout.razor
+++ b/Redot-Documentation/Components/Layout/MainLayout.razor
@@ -11,6 +11,7 @@
Skip to content
+
@if (HasNavigation)
{
@@ -18,13 +19,16 @@
Redot Docs
-
+
+
+
@if (HasNavigation)
{
diff --git a/Redot-Documentation/Components/Layout/SearchDialog.razor b/Redot-Documentation/Components/Layout/SearchDialog.razor
new file mode 100644
index 0000000..2390cbd
--- /dev/null
+++ b/Redot-Documentation/Components/Layout/SearchDialog.razor
@@ -0,0 +1,9 @@
+
+ Search documentation
+
+
+@code {
+ [CascadingParameter] private IMudDialogInstance Dialog { get; set; } = null!;
+ [Parameter] public string Version { get; set; } = "";
+ private void Close() => Dialog.Close();
+}
diff --git a/Redot-Documentation/Components/Layout/SearchPanel.razor b/Redot-Documentation/Components/Layout/SearchPanel.razor
new file mode 100644
index 0000000..b1bfd0d
--- /dev/null
+++ b/Redot-Documentation/Components/Layout/SearchPanel.razor
@@ -0,0 +1,187 @@
+@using Redot_Documentation.Search
+@using Redot_Documentation.Services
+@using System.Text.RegularExpressions
+@using System.Net
+@implements IAsyncDisposable
+@inject IJSRuntime JS
+@inject IDocumentationSearch SearchService
+@inject VersionManagerService Versions
+@inject NavigationManager Navigation
+
+
+
+
+
+ @foreach (var version in Versions.Versions) { @version.FriendlyName }
+
+
+ All
+ Guides & tutorials
+ Class reference
+
+
+
+
+ @if (_displayQuery.Length == 0) { Search explanations, code samples, and API names. }
+ else if (!_response.Available) { Search is preparing this version. Please try again shortly. }
+ else { @_response.Hits.Count@(_response.HasMore ? "+" : "") results }
+
+ Searching…
+ @(_selected >= 0 ? $"{_response.Hits[_selected].Title}: {_response.Hits[_selected].Heading}" : "")
+
+
+ @if (_response.Available && _displayQuery.Length > 0 && _response.Hits.Count == 0)
+ {
No matches. Try fewer words or a different version. }
+
+
+
+ @if (!FullPage && _query.Trim().Length > 0)
+ { View all results }
+ @if (FullPage && _response.HasMore && _limit < 100)
+ { Show more results }
+
+
+
+@code {
+ [Parameter] public string? Query { get; set; }
+ [Parameter] public string? Version { get; set; }
+ [Parameter] public string? Kind { get; set; }
+ [Parameter] public bool FullPage { get; set; }
+ [Parameter] public EventCallback ResultChosen { get; set; }
+ private string _query = "", _version = "", _kind = "all";
+ private int _selected = -1, _limit = 30;
+ private bool _busy, _showLoading, _disposed, _scrollSelection;
+ private long _requestId;
+ private string _displayQuery = "";
+ private (string?, string?, string?)? _parameters;
+ private ElementReference _panel;
+ private IJSObjectReference? _module;
+ private CancellationTokenSource? _pending;
+ private SearchResponse _response = new(true, []);
+ private string ResultsUrl => $"/search?q={Uri.EscapeDataString(_query)}&version={Uri.EscapeDataString(_version)}&kind={_kind}";
+ protected override async Task OnParametersSetAsync()
+ {
+ var incoming = (Query, Version, Kind);
+ if (_parameters == incoming) return;
+ _parameters = incoming;
+ string query = Query ?? "";
+ string version = Versions.TryGetVersionProvider(Version ?? "", out _) ? Version! : Versions.LatestStableVersion.Slug;
+ string kind = Kind is "guides" or "classes" ? Kind : "all";
+ if ((_query, _version, _kind) == (query, version, kind)) return;
+ (_query, _version, _kind) = (query, version, kind);
+ _limit = 30;
+ await Run(false);
+ }
+ private async Task QueryChanged(string value) { _query = value; _limit = 30; await Run(true); }
+ private async Task VersionChanged(string value) { _version = value; await Run(false); }
+ private async Task KindChanged(string value) { _kind = value; await Run(false); }
+ private async Task More() { _limit += 30; await Run(false); }
+ private async Task Run(bool debounce)
+ {
+ _pending?.Cancel();
+ long request = ++_requestId;
+ var pending = _pending = new CancellationTokenSource();
+ string query = _query.Trim(), version = _version, kind = _kind;
+ int limit = FullPage ? _limit : 10;
+ _busy = query.Length > 0;
+ _showLoading = false;
+ _selected = -1;
+ _scrollSelection = false;
+ try
+ {
+ if (query.Length == 0)
+ {
+ _response = new(true, []);
+ _displayQuery = "";
+ SyncUrl();
+ return;
+ }
+ _ = ShowLoadingAsync(request, pending.Token);
+ if (debounce) await Task.Delay(200, pending.Token);
+ var result = await Task.Run(() => SearchService.Search(version, query, kind, limit), pending.Token);
+ if (_disposed || request != _requestId || pending.IsCancellationRequested) return;
+ _response = result;
+ _displayQuery = query;
+ SyncUrl();
+ }
+ catch (OperationCanceledException) { }
+ finally
+ {
+ pending.Cancel();
+ if (request == _requestId) { _busy = false; _showLoading = false; _pending = null; }
+ pending.Dispose();
+ }
+ }
+ private void SyncUrl()
+ {
+ if (FullPage && new Uri(Navigation.Uri).PathAndQuery != ResultsUrl)
+ Navigation.NavigateTo(ResultsUrl, replace: true);
+ }
+ private async Task ShowLoadingAsync(long request, CancellationToken token)
+ {
+ try
+ {
+ await Task.Delay(400, token);
+ await InvokeAsync(() =>
+ {
+ if (!_disposed && request == _requestId && _busy)
+ { _showLoading = true; StateHasChanged(); }
+ });
+ }
+ catch (OperationCanceledException) { }
+ }
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender) _module = await JS.InvokeAsync("import", "./js/search.js");
+ if (_scrollSelection && _module is not null)
+ {
+ _scrollSelection = false;
+ await _module.InvokeVoidAsync("scrollSelected", _panel);
+ }
+ }
+ private async Task HandleKey(KeyboardEventArgs args)
+ {
+ if (_busy || _response.Hits.Count == 0) return;
+ if (args.Key == "ArrowDown") _selected = (_selected + 1) % _response.Hits.Count;
+ if (args.Key == "ArrowUp") _selected = (_selected + _response.Hits.Count - 1) % _response.Hits.Count;
+ if (args.Key is "ArrowDown" or "ArrowUp") _scrollSelection = true;
+ if (args.Key == "Enter")
+ {
+ var hit = _response.Hits[Math.Max(0,_selected)];
+ await ResultChosen.InvokeAsync();
+ Navigation.NavigateTo(hit.Url);
+ }
+ }
+ private MarkupString Highlight(string text)
+ {
+ var terms = SearchContent.Terms(_displayQuery).OrderByDescending(t => t.Length).Select(Regex.Escape).ToArray();
+ if (terms.Length == 0) return new(WebUtility.HtmlEncode(text));
+ var output = new System.Text.StringBuilder();
+ int start = 0;
+ foreach (Match match in Regex.Matches(text,string.Join('|',terms),RegexOptions.IgnoreCase))
+ {
+ output.Append(WebUtility.HtmlEncode(text[start..match.Index]));
+ output.Append("").Append(WebUtility.HtmlEncode(match.Value)).Append("");
+ start = match.Index + match.Length;
+ }
+ return new(output.Append(WebUtility.HtmlEncode(text[start..])).ToString());
+ }
+ public async ValueTask DisposeAsync()
+ {
+ _disposed = true;
+ ++_requestId;
+ _pending?.Cancel();
+ try { if (_module is not null) await _module.DisposeAsync(); }
+ catch (JSDisconnectedException) { }
+ }
+}
diff --git a/Redot-Documentation/Components/Pages/DocViewer.razor b/Redot-Documentation/Components/Pages/DocViewer.razor
index d354b34..2d63a7e 100644
--- a/Redot-Documentation/Components/Pages/DocViewer.razor
+++ b/Redot-Documentation/Components/Pages/DocViewer.razor
@@ -41,22 +41,6 @@ else
}
@code {
- private static readonly Regex HeadingRegex = new(
- @"[^>]*)>(?.*?)",
- RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
-
- private static readonly Regex IdRegex = new(
- @"\sid\s*=\s*[\""'](?[^\""']+)[\""']",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
-
- private static readonly Regex HtmlTagRegex = new(
- @"<[^>]+>",
- RegexOptions.Compiled);
-
- private static readonly Regex WhitespaceRegex = new(
- @"\s+",
- RegexOptions.Compiled);
-
[Parameter]
public string? DocumentPath { get; set; }
@@ -80,7 +64,7 @@ else
: $"{docPath}.md";
var html = await DocRendererService.RenderToHtmlAsync(path, versionProvider);
- renderedHtml = BuildTableOfContentsAndEnsureHeadingIds(html);
+ renderedHtml = DocumentHeadings.Apply(html, tableOfContents);
}
catch (Exception ex)
{
@@ -119,72 +103,6 @@ else
return (VersionManagerService.GetVersionProvider(VersionManagerService.LatestStableVersion.Slug), normalizedPath);
}
- private string BuildTableOfContentsAndEnsureHeadingIds(string html)
- {
- tableOfContents.Clear();
-
- var usedIds = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- return HeadingRegex.Replace(html, match =>
- {
- var level = int.Parse(match.Groups[1].Value);
- var attributes = match.Groups["attributes"].Value;
- var content = match.Groups["content"].Value;
- var title = DecodeHeadingTitle(content);
-
- var existingIdMatch = IdRegex.Match(attributes);
- if (string.IsNullOrWhiteSpace(title))
- {
- if (existingIdMatch.Success)
- {
- usedIds.Add(existingIdMatch.Groups["id"].Value);
- }
- return match.Value;
- }
- var id = existingIdMatch.Success
- ? existingIdMatch.Groups["id"].Value
- : HeadingAnchor.FromTitle(title);
-
- id = EnsureUniqueId(id, usedIds);
-
- if (existingIdMatch.Success && !string.Equals(existingIdMatch.Groups["id"].Value, id, StringComparison.Ordinal))
- {
- attributes = IdRegex.Replace(attributes, $" id=\"{id}\"", 1);
- }
- else if (!existingIdMatch.Success)
- {
- attributes = $" id=\"{id}\"{attributes}";
- }
-
- tableOfContents.Add(new DocumentHeading(level, id, title));
- return $"{content}";
- });
- }
-
- private static string DecodeHeadingTitle(string content)
- {
- var withoutTags = HtmlTagRegex.Replace(content, string.Empty);
- var decoded = System.Net.WebUtility.HtmlDecode(withoutTags);
- return WhitespaceRegex.Replace(decoded, " ").Trim();
- }
-
- private static string EnsureUniqueId(string id, HashSet usedIds)
- {
- var normalizedId = string.IsNullOrWhiteSpace(id) ? "section" : id;
- if (usedIds.Add(normalizedId))
- {
- return normalizedId;
- }
-
- var suffix = 2;
- while (!usedIds.Add($"{normalizedId}-{suffix}"))
- {
- suffix++;
- }
-
- return $"{normalizedId}-{suffix}";
- }
-
private string BuildTableOfContentsHref(string id)
{
var normalizedPath = (DocumentPath ?? string.Empty).Trim('/');
diff --git a/Redot-Documentation/Components/Pages/Licenses.razor b/Redot-Documentation/Components/Pages/Licenses.razor
index 720ac83..236f9ab 100644
--- a/Redot-Documentation/Components/Pages/Licenses.razor
+++ b/Redot-Documentation/Components/Pages/Licenses.razor
@@ -51,6 +51,8 @@
See the media notice and credits in the relevant pages.