From 0946108b470ab26ee3dec43c1f44c6d0f98275c0 Mon Sep 17 00:00:00 2001 From: Arctis_Fireblight <6182060+Arctis-Fireblight@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:18:47 -0500 Subject: [PATCH 1/2] Refactored document path handling with `DocumentPathResolver` and updated related tests and services. --- .../ClassDocumentationComponentTests.cs | 63 +++++- .../DocRendererServiceTests.cs | 23 +- .../DocumentPathResolverTests.cs | 197 ++++++++++++++++++ .../DocumentationSearchTests.cs | 50 ++++- .../VersionManagerServiceTests.cs | 18 +- .../Components/Layout/NavMenu.razor | 39 ++-- .../Components/Layout/NavSectionTree.razor | 25 ++- .../Components/Pages/DocViewer.razor | 74 +++---- Redot-Documentation/Program.cs | 2 + .../Search/DocumentationSearchService.cs | 13 +- .../Services/DocRendererService.cs | 36 +--- .../Services/DocumentPathResolver.cs | 97 +++++++++ .../Versioning/VersionProvider.cs | 2 +- Redot-Documentation/wwwroot/app.css | 3 +- 14 files changed, 523 insertions(+), 119 deletions(-) create mode 100644 Redot-Documentation-Tests/DocumentPathResolverTests.cs create mode 100644 Redot-Documentation/Services/DocumentPathResolver.cs diff --git a/Redot-Documentation-Tests/ClassDocumentationComponentTests.cs b/Redot-Documentation-Tests/ClassDocumentationComponentTests.cs index 0098f66..de29268 100644 --- a/Redot-Documentation-Tests/ClassDocumentationComponentTests.cs +++ b/Redot-Documentation-Tests/ClassDocumentationComponentTests.cs @@ -187,6 +187,54 @@ public async Task ClassReference_IncludesUpstreamMitAttribution(string? classNam Assert.Empty(renderer.Errors); } + [Theory] + [InlineData("26.1/tutorials/MIXED.HTML")] + [InlineData("26.1/TUTORIALS/mixed.MD")] + [InlineData("tutorials/mixed")] + public async Task Manual_LegacyAliasesRenderAndHeadingLinksPreserveQuery(string route) + { + Directory.CreateDirectory(Path.Combine(_root, "docs", "26.1", "Tutorials")); + File.WriteAllText(Path.Combine(_root, "docs", "26.1", "Tutorials", "Mixed.md"), "# Mixed heading"); + await using var services = CreateServices(); + services.GetRequiredService().NavigateTo("/en/" + route + "?source=Legacy#Mixed"); + await using var renderer = new TestRenderer(services); + int id = await renderer.Dispatcher.InvokeAsync(() => renderer.RenderAsync(typeof(DocViewer), + ParameterView.FromDictionary(new Dictionary { ["DocumentPath"] = route }))); + Assert.Contains("Mixed heading", await renderer.Dispatcher.InvokeAsync(() => renderer.Text(id))); + Assert.Contains("/en/26.1/Tutorials/Mixed?source=Legacy#mixed-heading", + await renderer.Dispatcher.InvokeAsync(() => renderer.Hrefs(id).ToArray())); + Assert.Empty(renderer.Errors); + } + + [Theory] + [InlineData("/en/26.1/tutorials/MIXED.HTML?source=Legacy#SomeHeading", "/en/latest/Tutorials/Mixed?source=Legacy#SomeHeading")] + [InlineData("/en/tutorials/mixed.md?source=Legacy#SomeHeading", "/en/latest/Tutorials/Mixed?source=Legacy#SomeHeading")] + [InlineData("/en/about/INTRO.HTML?source=Legacy#SomeHeading", "/en/About/Intro?source=Legacy#SomeHeading")] + public async Task NavMenu_VersionSwitchResolvesAliasesAndPreservesQueryAndFragment(string source, string expected) + { + foreach (var version in new[] { "26.1", "latest" }) + { + Directory.CreateDirectory(Path.Combine(_root, "docs", version, "Tutorials")); + File.WriteAllText(Path.Combine(_root, "docs", version, "Tutorials", "Mixed.md"), "# Mixed"); + } + Directory.CreateDirectory(Path.Combine(_root, "docs", "About")); + File.WriteAllText(Path.Combine(_root, "docs", "About", "Intro.md"), "# Intro"); + await using var services = CreateServices(); + var navigation = services.GetRequiredService(); + navigation.NavigateTo(source); + await using var renderer = new TestRenderer(services); + int id = await renderer.Dispatcher.InvokeAsync(() => renderer.RenderAsync(typeof(NavMenu), ParameterView.Empty)); + Assert.Contains("doc-current", await renderer.Dispatcher.InvokeAsync(() => renderer.CssClasses(id).ToArray())); + await renderer.Dispatcher.InvokeAsync(async () => + { + var select = renderer.FindComponent>(id); + Assert.NotNull(select); + await select.ValueChanged.InvokeAsync("latest"); + }); + Assert.Equal("http://localhost" + expected, navigation.Uri); + Assert.Empty(renderer.Errors); + } + [Fact] public async Task Manual_IncludesCcAttributionAndModificationNotice() { @@ -265,7 +313,8 @@ private ServiceProvider CreateServices(IDocumentationSearch? search = null) return new ServiceCollection().AddLogging() .AddSingleton(search ?? new ControlledSearch()) .AddMudServices(options => options.PopoverOptions.CheckForPopoverProvider = false) - .AddSingleton(new DocRendererService(new TestEnvironment(_root))) + .AddSingleton(_ => new DocumentPathResolver(new TestEnvironment(_root), manager)) + .AddSingleton() .AddSingleton(manager) .AddSingleton() .AddSingleton() @@ -306,6 +355,18 @@ public IEnumerable Hrefs(int id) } } + public IEnumerable CssClasses(int id) + { + var frames = GetCurrentRenderTreeFrames(id); + foreach (var frame in frames.Array.Take(frames.Count)) + { + if (frame.FrameType == RenderTreeFrameType.Attribute && frame.AttributeName == "class" && frame.AttributeValue is string css) + foreach (var name in css.Split(' ', StringSplitOptions.RemoveEmptyEntries)) yield return name; + if (frame.FrameType == RenderTreeFrameType.Component) + foreach (var name in CssClasses(frame.ComponentId)) yield return name; + } + } + public T? FindComponent(int id) where T : class { var frames = GetCurrentRenderTreeFrames(id); diff --git a/Redot-Documentation-Tests/DocRendererServiceTests.cs b/Redot-Documentation-Tests/DocRendererServiceTests.cs index dea2939..82806b0 100644 --- a/Redot-Documentation-Tests/DocRendererServiceTests.cs +++ b/Redot-Documentation-Tests/DocRendererServiceTests.cs @@ -13,10 +13,10 @@ public sealed class DocRendererServiceTests : IDisposable $"redot-documentation-tests-{Guid.NewGuid():N}"); [Theory] - [InlineData("doc_some_doc#some_section", "/en/About/some_doc.md#some-section")] - [InlineData("doc_some_doc#some-section", "/en/About/some_doc.md#some-section")] - [InlineData("doc_some_doc#Some%20Section", "/en/About/some_doc.md#some-section")] - [InlineData("doc_some_doc", "/en/About/some_doc.md")] + [InlineData("doc_some_doc#some_section", "/en/About/some_doc#some-section")] + [InlineData("doc_some_doc#some-section", "/en/About/some_doc#some-section")] + [InlineData("doc_some_doc#Some%20Section", "/en/About/some_doc#some-section")] + [InlineData("doc_some_doc", "/en/About/some_doc")] public async Task RenderToHtmlAsync_ResolvesDocumentSlugsWithOptionalSections( string target, string expectedHref) @@ -26,7 +26,8 @@ await File.WriteAllTextAsync( Path.Combine(contentRootPath, "docs", "source.md"), $"[Some Section]({target})"); - var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath)); + var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath), + new VersionManagerService(new TestWebHostEnvironment(contentRootPath)))); var html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider()); @@ -41,7 +42,8 @@ await File.WriteAllTextAsync( Path.Combine(contentRootPath, "docs", "source.md"), "[Missing](doc_missing#some_section)"); - var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath)); + var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath), + new VersionManagerService(new TestWebHostEnvironment(contentRootPath)))); var html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider()); @@ -63,7 +65,8 @@ await File.WriteAllTextAsync( Path.Combine(contentRootPath, "docs", "source.md"), $"[Class reference]({target})"); - var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath)); + var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath), + new VersionManagerService(new TestWebHostEnvironment(contentRootPath)))); string html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider()); @@ -88,7 +91,8 @@ Nested content. """); - var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath)); + var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath), + new VersionManagerService(new TestWebHostEnvironment(contentRootPath)))); string html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider()); @@ -110,7 +114,8 @@ await File.WriteAllTextAsync(Path.Combine(contentRootPath, "docs", "source.md"), Third sample. """); - var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath)); + var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath), + new VersionManagerService(new TestWebHostEnvironment(contentRootPath)))); string html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider()); TabMarkupAssertions.AssertAccessibleTabs(html, 3); } diff --git a/Redot-Documentation-Tests/DocumentPathResolverTests.cs b/Redot-Documentation-Tests/DocumentPathResolverTests.cs new file mode 100644 index 0000000..d9ab685 --- /dev/null +++ b/Redot-Documentation-Tests/DocumentPathResolverTests.cs @@ -0,0 +1,197 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; +using Redot_Documentation.Services; +using Redot_Documentation.Versioning; + +namespace Redot_Documentation_Tests; + +public sealed class DocumentPathResolverTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), $"redot-path-tests-{Guid.NewGuid():N}"); + private readonly VersionManagerService versions; + private readonly IWebHostEnvironment environment; + + public DocumentPathResolverTests() + { + environment = new TestEnvironment(root); + Write("26.1/Tutorials/Physics/ray-casting.md", "# Stable physics"); + Write("latest/Tutorials/Physics/ray-casting.md", "# Development physics"); + Write("About/Introduction.md", "# Shared introduction"); + Write("26.1/About/Introduction.md", "# Version introduction"); + Write("About/index.json", "{\"SlugPrefix\":\"abt_\"}"); + Write("26.1/Tutorials/Space ΓΌ.md", "# Unicode"); + Write("26.1/OnlyStable.md", "# Only stable"); + File.WriteAllText(Path.Combine(root, "docs", "Versions.json"), JsonSerializer.Serialize(new[] + { + new DocumentationVersion { Slug = "26.1", FriendlyName = "Stable", BranchName = "26.1", IsLatestStable = true }, + new DocumentationVersion { Slug = "latest", FriendlyName = "Development", BranchName = "master", IsNextPrerelease = true } + })); + versions = new VersionManagerService(environment); + versions.LoadContent(); + } + + [Theory] + [InlineData("26.1/tutorials/PHYSICS/RAY-CASTING")] + [InlineData("26.1/tutorials/PHYSICS/RAY-CASTING.md")] + [InlineData("26.1/tutorials/PHYSICS/RAY-CASTING.MD")] + [InlineData("26.1/tutorials/PHYSICS/RAY-CASTING.html")] + [InlineData("26.1/tutorials/PHYSICS/RAY-CASTING.HTML")] + [InlineData("/26.1/tutorials/PHYSICS/RAY-CASTING.HTML/")] + [InlineData("tutorials/physics/ray-casting.html")] + public async Task AliasesResolveAndRenderTheSamePhysicalDocument(string route) + { + var paths = new DocumentPathResolver(environment, versions); + var document = Assert.IsType(paths.ResolveRoute(route, versions)); + Assert.Equal(Path.Combine(root, "docs", "26.1", "Tutorials", "Physics", "ray-casting.md"), document.FullPath); + Assert.Equal("/en/26.1/Tutorials/Physics/ray-casting", document.PublicUrl); + var (provider, path) = DocumentPathResolver.ParseRoute(route, versions); + var html = await new DocRendererService(paths).RenderToHtmlAsync(path, provider); + Assert.Contains("Stable physics", html); + } + + [Theory] + [InlineData("about/INTRODUCTION.HTML")] + [InlineData("26.1/about/INTRODUCTION.md")] + [InlineData("LATEST/about/introduction")] + public void SharedDocumentsKeepPrecedence(string route) + { + var document = new DocumentPathResolver(environment, versions).ResolveRoute(route, versions); + Assert.Equal("About/Introduction.md", document?.RelativePath); + } + + [Fact] + public void VersionIsCaseInsensitiveButMissingContentDoesNotFallBackToAnotherVersion() + { + var paths = new DocumentPathResolver(environment, versions); + Assert.Equal("latest/Tutorials/Physics/ray-casting.md", + paths.ResolveRoute("LATEST/tutorials/physics/ray-casting.HTML", versions)?.RelativePath); + Assert.Null(paths.ResolveRoute("latest/OnlyStable.html", versions)); + Assert.Null(paths.ResolveRoute("99.0/Tutorials/Physics/ray-casting", versions)); + Assert.Null(paths.ResolveRoute("26.1/missing.html", versions)); + Assert.Null(paths.ResolveRoute("26.1/Tutorials/Physics/ray-casting.htm", versions)); + Assert.Null(paths.ResolveRoute("26.1/Tutorials/Physics/ray-casting.html.md", versions)); + Assert.Null(paths.ResolveRoute("", versions)); + } + + [Theory] + [InlineData("latest/26.1/OnlyStable.html")] + [InlineData("LATEST/26.1/Tutorials/Physics/ray-casting.MD")] + [InlineData("26.1/LATEST/Tutorials/Physics/ray-casting.HTML")] + public void NestedVersionPrefixesCannotSelectAnotherVersionsDocument(string route) + { + var paths = new DocumentPathResolver(environment, versions); + Assert.Null(paths.ResolveRoute(route, versions)); + } + + [Theory] + [InlineData("../outside")] + [InlineData("26.1/../../docs-other/secret.md")] + [InlineData("26.1/Tutorials/./Physics/ray-casting")] + [InlineData("26.1\\Tutorials\\Physics\\ray-casting")] + [InlineData("26.1/file.md?x=1")] + [InlineData("26.1/file.html#Heading")] + [InlineData("26.1/file\0.md")] + public void RejectsInvalidPaths(string route) + => Assert.Throws(() => new DocumentPathResolver(environment, versions).ResolveRoute(route, versions)); + + [Fact] + public void EncodedCharactersAreDecodedOnlyAtTheUrlBoundary() + { + var paths = new DocumentPathResolver(environment, versions); + var route = Uri.UnescapeDataString("26.1/tutorials/space%20%C3%BC.HTML"); + Assert.Equal("/en/26.1/Tutorials/Space%20%C3%BC", paths.ResolveRoute(route, versions)?.PublicUrl); + Assert.Null(paths.ResolveRoute("26.1/%2e%2e/About/Introduction", versions)); + } + + [Fact] + public void CaseCollisionsIdentifyBothFiles() + { + // Probe the actual test volume before creating a second, differently cased file. + if (File.Exists(Path.Combine(root, "docs", "26.1", "tutorials", "physics", "RAY-CASTING.md"))) + return; + Write("26.1/tutorials/physics/RAY-CASTING.md", "# Collision"); + var error = Assert.Throws(() => new DocumentPathResolver(environment, versions)); + Assert.Contains("26.1/Tutorials/Physics/ray-casting.md", error.Message); + Assert.Contains("26.1/tutorials/physics/RAY-CASTING.md", error.Message); + } + + [Fact] + public void DoesNotIndexNonMarkdownOrSymbolicLinks() + { + Write("26.1/private.txt", "Private"); + if (!OperatingSystem.IsWindows()) + File.CreateSymbolicLink(Path.Combine(root, "docs", "26.1", "alias.md"), + Path.Combine(root, "docs", "26.1", "private.txt")); + var paths = new DocumentPathResolver(environment, versions); + Assert.Null(paths.ResolveRoute("26.1/private.txt", versions)); + Assert.Null(paths.ResolveRoute("26.1/alias", versions)); + } + + [Theory] + [InlineData("../../../docs-private/secret.md")] + [InlineData("26.1/../../docs-private/secret.html")] + [InlineData("26.1/%2e%2e/%2e%2e/docs-private/secret.md")] + [InlineData("26.1/%2E%2E%2F%2E%2E%2Fdocs-private%2Fsecret.HTML")] + [InlineData("26.1/%252e%252e/%252e%252e/docs-private/secret.md")] + [InlineData("26.1/..%5c..%5cdocs-private%5csecret.md")] + [InlineData("26.1/..%255c..%255cdocs-private%255csecret.md")] + [InlineData("26.1/..;/..;/docs-private/secret.md")] + [InlineData("26.1/%c0%ae%c0%ae/%c0%ae%c0%ae/docs-private/secret.md")] + [InlineData("26.1/../../docs-private/secret.md%00.html")] + public async Task TraversalCannotRenderExistingFilesOutsideDocs(string route) + { + var outside = Path.Combine(root, "docs-private"); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(outside, "secret.md"), "OUTSIDE_DOCS_SENTINEL"); + var paths = new DocumentPathResolver(environment, versions); + var renderer = new DocRendererService(paths); + // Check raw, once-decoded and twice-decoded forms independently: none may + // reach the existing sibling file, regardless of upstream URL decoding. + for (var pass = 0; pass < 3; pass++) + { + var (provider, path) = DocumentPathResolver.ParseRoute(route, versions); + var error = await Record.ExceptionAsync(() => renderer.RenderToHtmlAsync(path, provider)); + Assert.True(error is ArgumentException or FileNotFoundException, + $"Traversal was not rejected: {route}; exception: {error}"); + route = Uri.UnescapeDataString(route); + } + var absoluteError = await Record.ExceptionAsync(() => renderer.RenderToHtmlAsync( + Path.Combine(outside, "secret.md"), versions.GetVersionProvider("26.1"))); + Assert.True(absoluteError is ArgumentException or FileNotFoundException); + } + + [Fact] + public void DoesNotFollowFileOrDirectoryLinksOutsideDocs() + { + if (OperatingSystem.IsWindows()) return; + var outside = Path.Combine(root, "docs-private"); + Directory.CreateDirectory(outside); + File.WriteAllText(Path.Combine(outside, "secret.md"), "OUTSIDE_DOCS_SENTINEL"); + File.CreateSymbolicLink(Path.Combine(root, "docs", "26.1", "external.md"), + Path.Combine(outside, "secret.md")); + Directory.CreateSymbolicLink(Path.Combine(root, "docs", "26.1", "external-folder"), outside); + var paths = new DocumentPathResolver(environment, versions); + Assert.Null(paths.ResolveRoute("26.1/external.HTML", versions)); + Assert.Null(paths.ResolveRoute("26.1/external-folder/secret.HTML", versions)); + } + + private void Write(string path, string text) + { + var file = Path.Combine(root, "docs", path); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + File.WriteAllText(file, text); + } + + public void Dispose() => Directory.Delete(root, true); + + 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-Tests/DocumentationSearchTests.cs b/Redot-Documentation-Tests/DocumentationSearchTests.cs index 1764f09..9b04068 100644 --- a/Redot-Documentation-Tests/DocumentationSearchTests.cs +++ b/Redot-Documentation-Tests/DocumentationSearchTests.cs @@ -57,7 +57,7 @@ await File.WriteAllTextAsync(Path.Combine(root, "docs", "Versions.json"), JsonSe var versions = new VersionManagerService(env); versions.LoadContent(); var catalog = new ClassDocumentationCatalog(); var logger = new TestLogger(); - using var service = new DocumentationSearchService(versions, catalog, env, logger); + using var service = new DocumentationSearchService(versions, catalog, env, logger, new DocumentPathResolver(env, versions)); await service.StartAsync(default); await Wait(() => service.Search("latest", "physics").Available); string cache = Path.Combine(root, "App_Data", "search", "stable"); @@ -104,13 +104,59 @@ await File.WriteAllTextAsync(Path.Combine(root, "docs", "Versions.json"), JsonSe 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); + using var cached = new DocumentationSearchService(versions, catalog, env, NullLogger.Instance, new DocumentPathResolver(env, versions)); await cached.StartAsync(default); await Wait(() => cached.Search("stable", "replacement").Hits.Count == 1); await cached.StopAsync(default); } finally { Directory.Delete(root, true); } } + [Fact] + public async Task Index_IncludesUppercaseMarkdownAndExcludesSymlinkedFilesAndDirectories() + { + string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + try + { + foreach (var version in new[] { "stable", "latest" }) + Directory.CreateDirectory(Path.Combine(root, "docs", version)); + await File.WriteAllTextAsync(Path.Combine(root, "docs", "stable", "Upper.MD"), + "# Uppercase document\n\nUppercaseextensiontoken"); + 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 outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(outside); + var secret = Path.Combine(outside, "secret.md"); + await File.WriteAllTextAsync(secret, "# Private\n\nOutsidesentineltoken"); + if (!OperatingSystem.IsWindows()) + { + File.CreateSymbolicLink(Path.Combine(root, "docs", "stable", "linked.md"), secret); + Directory.CreateSymbolicLink(Path.Combine(root, "docs", "stable", "linked-folder"), outside); + Directory.CreateSymbolicLink(Path.Combine(root, "docs", "Community"), outside); + } + var logger = new TestLogger(); + using var service = new DocumentationSearchService(versions, new ClassDocumentationCatalog(), env, + logger, new DocumentPathResolver(env, versions)); + await service.StartAsync(default); + try + { + await Wait(() => service.Search("latest", "anything").Available); + var hit = Assert.Single(service.Search("stable", "Uppercaseextensiontoken").Hits); + Assert.StartsWith("/en/stable/Upper", hit.Url); + Assert.Empty(service.Search("latest", "Uppercaseextensiontoken").Hits); + Assert.Empty(service.Search("stable", "Outsidesentineltoken").Hits); + Assert.Empty(service.Search("latest", "Outsidesentineltoken").Hits); + Assert.Equal(0, logger.Errors); + } + finally { await service.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); } diff --git a/Redot-Documentation-Tests/VersionManagerServiceTests.cs b/Redot-Documentation-Tests/VersionManagerServiceTests.cs index 8af12e0..b671ba7 100644 --- a/Redot-Documentation-Tests/VersionManagerServiceTests.cs +++ b/Redot-Documentation-Tests/VersionManagerServiceTests.cs @@ -57,13 +57,13 @@ public void LoadContent_RepositoryDocumentationHasUniqueSlugs() manager.LoadContent(); VersionProvider provider = manager.GetVersionProvider("latest"); - Assert.Equal("/en/Community/tutorials.md", provider.GetPathFromSlug("doc_tutorials")); - Assert.Equal("/en/latest/Tutorials/index.md", provider.GetPathFromSlug("doc_tutorials_overview")); - Assert.Equal("/en/latest/Tutorials/math/interpolation.md", provider.GetPathFromSlug("doc_interpolation")); - Assert.Equal("/en/latest/Tutorials/physics/interpolation/index.md", provider.GetPathFromSlug("doc_physics_interpolation")); - Assert.Equal("/en/latest/Tutorials/editor/index.md", provider.GetPathFromSlug("doc_editor")); - Assert.Equal("/en/latest/Tutorials/plugins/editor/index.md", provider.GetPathFromSlug("doc_editor_plugins")); - Assert.Equal("/en/latest/Tutorials/plugins/editor/making_plugins.md", provider.GetPathFromSlug("doc_making_plugins")); + Assert.Equal("/en/Community/tutorials", provider.GetPathFromSlug("doc_tutorials")); + Assert.Equal("/en/latest/Tutorials/index", provider.GetPathFromSlug("doc_tutorials_overview")); + Assert.Equal("/en/latest/Tutorials/math/interpolation", provider.GetPathFromSlug("doc_interpolation")); + Assert.Equal("/en/latest/Tutorials/physics/interpolation/index", provider.GetPathFromSlug("doc_physics_interpolation")); + Assert.Equal("/en/latest/Tutorials/editor/index", provider.GetPathFromSlug("doc_editor")); + Assert.Equal("/en/latest/Tutorials/plugins/editor/index", provider.GetPathFromSlug("doc_editor_plugins")); + Assert.Equal("/en/latest/Tutorials/plugins/editor/making_plugins", provider.GetPathFromSlug("doc_making_plugins")); } [Fact] @@ -83,8 +83,8 @@ public void LoadContent_DuplicateArticleAndSectionSlugsIdentifyBothPaths() InvalidOperationException exception = Assert.Throws(manager.LoadContent); Assert.Contains("Duplicate documentation slug 'doc_example' in version 'latest'", exception.Message); - Assert.Contains("/en/latest/example.md", exception.Message); - Assert.Contains("/en/latest/example/index.md", exception.Message); + Assert.Contains("/en/latest/example", exception.Message); + Assert.Contains("/en/latest/example/index", exception.Message); } [Fact] diff --git a/Redot-Documentation/Components/Layout/NavMenu.razor b/Redot-Documentation/Components/Layout/NavMenu.razor index c888345..be01d9f 100644 --- a/Redot-Documentation/Components/Layout/NavMenu.razor +++ b/Redot-Documentation/Components/Layout/NavMenu.razor @@ -3,6 +3,7 @@ @using Redot_Documentation.ClassDocumentation @implements IDisposable @inject VersionManagerService VersionManagerService +@inject DocumentPathResolver Paths @inject NavigationManager NavigationManager @inject ClassDocumentationCatalog ClassDocumentationCatalog @@ -20,7 +21,7 @@ { @if (ranking is Article article) { - @NormalizeArticleName(article.DisplayName) + @NormalizeArticleName(article.DisplayName) } else if (ranking is Section section) { @@ -83,12 +84,19 @@ private string GetArticleLink(Article article) { - string normalizedPath = _selectedVersionProvider!.GetReferentialPath(article.Path); - - if (normalizedPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) - normalizedPath = normalizedPath[..^3]; + return DocumentPathResolver.PublicUrl(Path.GetRelativePath( + Path.GetDirectoryName(_selectedVersionProvider!.VersionRoot)!, article.Path)); + } - return normalizedPath; + private bool IsCurrentArticle(Article article) + { + try + { + var path = Uri.UnescapeDataString(new Uri(NavigationManager.Uri).AbsolutePath); + if (!path.StartsWith("/en/", StringComparison.OrdinalIgnoreCase)) return false; + return Paths.ResolveRoute(path[4..], VersionManagerService)?.FullPath == article.Path; + } + catch (ArgumentException) { return false; } } private void SelectVersionFromLocation() @@ -154,13 +162,20 @@ return $"/en/{Uri.EscapeDataString(provider.Version.Slug)}/Classes"; } - if (segments.Length >= 3 && string.Equals(segments[0], "en", StringComparison.OrdinalIgnoreCase)) + if (segments.Length >= 2 && string.Equals(segments[0], "en", StringComparison.OrdinalIgnoreCase)) { - string document = Uri.UnescapeDataString(string.Join('/', segments.Skip(2))); - string candidate = Path.GetFullPath(Path.Combine(provider.VersionRoot, document.EndsWith(".md", StringComparison.OrdinalIgnoreCase) ? document : document + ".md")); - if (candidate.StartsWith(provider.VersionRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) - && File.Exists(candidate)) - return provider.GetReferentialPath(candidate)[..^3] + new Uri(NavigationManager.Uri).Fragment; + try + { + var route = Uri.UnescapeDataString(string.Join('/', segments.Skip(1))); + var (_, documentPath) = DocumentPathResolver.ParseRoute(route, VersionManagerService); + var document = Paths.Resolve(documentPath, provider); + if (document is not null) + { + var location = new Uri(NavigationManager.Uri); + return document.PublicUrl + location.Query + location.Fragment; + } + } + catch (ArgumentException) { } } return GetVersionLandingPath(provider); } diff --git a/Redot-Documentation/Components/Layout/NavSectionTree.razor b/Redot-Documentation/Components/Layout/NavSectionTree.razor index 2d41ceb..a75c108 100644 --- a/Redot-Documentation/Components/Layout/NavSectionTree.razor +++ b/Redot-Documentation/Components/Layout/NavSectionTree.razor @@ -1,18 +1,21 @@ @using Redot_Documentation.Versioning +@using Redot_Documentation.Services @inject NavigationManager Navigation +@inject DocumentPathResolver Paths +@inject VersionManagerService Versions @if (_expanded) { @if (Section.IndexArticle is not null) { - Overview + Overview } @foreach (IRanking ranking in Section.GetSortedRankings()) { if (ranking is Article article) { - @article.DisplayName.Replace(".md", "").Replace('_', ' ') + @article.DisplayName.Replace(".md", "").Replace('_', ' ') } else if (ranking is Section child) { @@ -34,12 +37,18 @@ _lastLocation = Navigation.Uri; _expanded = ContainsCurrentArticle(Section); } - private bool ContainsCurrentArticle(Section section) + private bool Matches(Article article) { - string current = Uri.UnescapeDataString(new Uri(Navigation.Uri).AbsolutePath).TrimEnd('/'); - if (current.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) current = current[..^3]; - bool Matches(Article article) => current.Equals(Uri.UnescapeDataString(BuildArticleLink(article)).TrimEnd('/'), StringComparison.OrdinalIgnoreCase); - return (section.IndexArticle is not null && Matches(section.IndexArticle)) - || section.Articles.Any(Matches) || section.SubSections.Any(ContainsCurrentArticle); + try + { + var path = Uri.UnescapeDataString(new Uri(Navigation.Uri).AbsolutePath); + return path.StartsWith("/en/", StringComparison.OrdinalIgnoreCase) + && Paths.ResolveRoute(path[4..], Versions)?.FullPath == article.Path; + } + catch (ArgumentException) { return false; } } + + private bool ContainsCurrentArticle(Section section) + => (section.IndexArticle is not null && Matches(section.IndexArticle)) + || section.Articles.Any(Matches) || section.SubSections.Any(ContainsCurrentArticle); } diff --git a/Redot-Documentation/Components/Pages/DocViewer.razor b/Redot-Documentation/Components/Pages/DocViewer.razor index 2d63a7e..8cf2ab2 100644 --- a/Redot-Documentation/Components/Pages/DocViewer.razor +++ b/Redot-Documentation/Components/Pages/DocViewer.razor @@ -2,10 +2,10 @@ @page "/en/{*DocumentPath}" @layout MainLayout @using Redot_Documentation.Services -@using Redot_Documentation.Versioning -@using System.Text.RegularExpressions @inject DocRendererService DocRendererService @inject VersionManagerService VersionManagerService +@inject DocumentPathResolver Paths +@inject NavigationManager Navigation @inject IJSRuntime JS @@ -44,6 +44,7 @@ else [Parameter] public string? DocumentPath { get; set; } + private string pageUrl = "/en/"; private bool isLoading = true; private string renderedHtml = string.Empty; private string? errorMessage; @@ -56,19 +57,34 @@ else try { - var (versionProvider, docPath) = ResolveVersionProviderAndDocumentPath(); - var path = string.IsNullOrWhiteSpace(docPath) - ? "index.md" - : docPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) - ? docPath - : $"{docPath}.md"; - - var html = await DocRendererService.RenderToHtmlAsync(path, versionProvider); + var (versionProvider, docPath) = DocumentPathResolver.ParseRoute(DocumentPath ?? "", VersionManagerService); + var document = Paths.Resolve(docPath, versionProvider); + if (document is null) + { + tableOfContents.Clear(); + renderedHtml = string.Empty; + Navigation.NotFound(); + return; + } + pageUrl = document.PublicUrl; + var html = await DocRendererService.RenderToHtmlAsync(document, versionProvider); renderedHtml = DocumentHeadings.Apply(html, tableOfContents); } - catch (Exception ex) + catch (ArgumentException) { - errorMessage = ex.Message; + tableOfContents.Clear(); + renderedHtml = string.Empty; + Navigation.NotFound(); + } + catch (FileNotFoundException) + { + tableOfContents.Clear(); + renderedHtml = string.Empty; + Navigation.NotFound(); + } + catch (Exception) + { + errorMessage = "The document could not be loaded."; tableOfContents.Clear(); } finally @@ -83,39 +99,7 @@ else await JS.InvokeVoidAsync("highlightCode"); } - private (VersionProvider provider, string path) ResolveVersionProviderAndDocumentPath() - { - var normalizedPath = (DocumentPath ?? string.Empty).Trim('/'); - if (string.IsNullOrWhiteSpace(normalizedPath)) - { - return (VersionManagerService.GetVersionProvider(VersionManagerService.LatestStableVersion.Slug), normalizedPath); - } - - var segments = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries); - var versionSegment = segments[0]; - - if (VersionManagerService.TryGetVersionProvider(versionSegment, out VersionProvider? provider)) - { - var pathWithoutVersion = string.Join('/', segments.Skip(1)); - return (provider!, pathWithoutVersion); - } - - return (VersionManagerService.GetVersionProvider(VersionManagerService.LatestStableVersion.Slug), normalizedPath); - } - private string BuildTableOfContentsHref(string id) - { - var normalizedPath = (DocumentPath ?? string.Empty).Trim('/'); - if (normalizedPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) - { - normalizedPath = normalizedPath[..^3]; - } - - var pagePath = string.IsNullOrWhiteSpace(normalizedPath) - ? "/en/" - : $"/en/{normalizedPath}"; - - return $"{pagePath}#{Uri.EscapeDataString(id)}"; - } + => $"{pageUrl}{new Uri(Navigation.Uri).Query}#{Uri.EscapeDataString(id)}"; } diff --git a/Redot-Documentation/Program.cs b/Redot-Documentation/Program.cs index 03fda84..89e85d7 100644 --- a/Redot-Documentation/Program.cs +++ b/Redot-Documentation/Program.cs @@ -18,6 +18,7 @@ public static void Main(string[] args) .AddInteractiveWebAssemblyComponents(); builder.Services.AddMudServices(); builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); @@ -56,6 +57,7 @@ public static void Main(string[] args) .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(Client._Imports).Assembly); app.Services.GetService()?.LoadContent(); + _ = app.Services.GetRequiredService(); app.MapGet("/health/class-docs", ( ClassDocumentationCatalog catalog, VersionManagerService versionManager) => diff --git a/Redot-Documentation/Search/DocumentationSearchService.cs b/Redot-Documentation/Search/DocumentationSearchService.cs index 8e6d48a..4273b00 100644 --- a/Redot-Documentation/Search/DocumentationSearchService.cs +++ b/Redot-Documentation/Search/DocumentationSearchService.cs @@ -16,7 +16,7 @@ namespace Redot_Documentation.Search; public sealed class DocumentationSearchService(VersionManagerService versions, ClassDocumentationCatalog catalog, - IWebHostEnvironment environment, ILogger logger) : BackgroundService, IDocumentationSearch + IWebHostEnvironment environment, ILogger logger, DocumentPathResolver paths) : BackgroundService, IDocumentationSearch { private readonly object _gate = new(); private readonly Dictionary _indexes = new(StringComparer.OrdinalIgnoreCase); @@ -47,7 +47,7 @@ private async Task BuildAsync(string slug, CancellationToken ct) var provider = versions.GetVersionProvider(slug); string root = Path.Combine(environment.ContentRootPath, "docs"); var files = new[] { provider.VersionRoot, Path.Combine(root, "About"), Path.Combine(root, "Community"), Path.Combine(root, "Contributing") } - .Where(System.IO.Directory.Exists).SelectMany(p => System.IO.Directory.EnumerateFiles(p, "*.md", SearchOption.AllDirectories)).Order().ToArray(); + .SelectMany(DocumentPathResolver.EnumerateMarkdownFiles).Order().ToArray(); catalog.TryGetSnapshot(slug, out var snapshot); using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); hash.AppendData(Encoding.UTF8.GetBytes("search-schema-2:" + snapshot?.CommitSha)); @@ -84,13 +84,16 @@ private async Task BuildAsync(string slug, CancellationToken ct) using (var analyzer = new WhitespaceAnalyzer(LuceneVersion.LUCENE_48)) using (var writer = new IndexWriter(directory, new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer) { Similarity = new BM25Similarity() })) { - var renderer = new DocRendererService(environment); + var renderer = new DocRendererService(paths); foreach (string file in files) { ct.ThrowIfCancellationRequested(); string relative = Path.GetRelativePath(root, file).Replace('\\', '/'); - string html = DocumentHeadings.Apply(await renderer.RenderToHtmlAsync(relative, provider, ct), []); - string url = "/en/" + string.Join('/', relative[..^3].Split('/').Select(Uri.EscapeDataString)); + string documentPath = relative.StartsWith(provider.Version.Slug + "/", StringComparison.OrdinalIgnoreCase) + ? relative[(provider.Version.Slug.Length + 1)..] + : relative; + string html = DocumentHeadings.Apply(await renderer.RenderToHtmlAsync(documentPath, provider, ct), []); + string url = DocumentPathResolver.PublicUrl(relative); foreach (var entry in SearchContent.Extract(html, url, "guides")) Add(writer, entry); } if (snapshot is not null) diff --git a/Redot-Documentation/Services/DocRendererService.cs b/Redot-Documentation/Services/DocRendererService.cs index 270f139..59e7dd8 100644 --- a/Redot-Documentation/Services/DocRendererService.cs +++ b/Redot-Documentation/Services/DocRendererService.cs @@ -13,39 +13,23 @@ public class DocRendererService .UseAdvancedExtensions() .Build(); - private readonly string docsRootPath; + private readonly DocumentPathResolver paths; - public DocRendererService(IWebHostEnvironment webHostEnvironment) + public DocRendererService(DocumentPathResolver paths) { - docsRootPath = Path.Combine(webHostEnvironment.ContentRootPath, "docs"); + this.paths = paths; } public async Task RenderToHtmlAsync(string documentPath, VersionProvider versionProvider, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(documentPath)) - { - throw new ArgumentException("Document path cannot be null or empty.", nameof(documentPath)); - } - - var normalizedPath = documentPath.Replace('\\', '/').Trim('/'); - var fullPath = Path.GetFullPath(Path.Combine(docsRootPath, normalizedPath)); - - if (!fullPath.StartsWith(docsRootPath, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException("Document path points outside the docs directory."); - } - - if (!File.Exists(fullPath)) - { - fullPath = Path.GetFullPath(Path.Combine(versionProvider.VersionRoot, normalizedPath)); - if (!fullPath.StartsWith(docsRootPath, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException("Document path points outside the docs directory."); - } - if (!File.Exists(fullPath)) - throw new FileNotFoundException($"Markdown document not found: {documentPath}", fullPath); - } + var document = paths.Resolve(documentPath, versionProvider) + ?? throw new FileNotFoundException("Markdown document not found."); + return await RenderToHtmlAsync(document, versionProvider, cancellationToken); + } + internal async Task RenderToHtmlAsync(ResolvedDocument document, VersionProvider versionProvider, CancellationToken cancellationToken = default) + { + var fullPath = document.FullPath; var markdown = await File.ReadAllTextAsync(fullPath, cancellationToken); var transformedMarkdown = TransformMarkdown(markdown, versionProvider); var renderedHtml = Markdown.ToHtml(transformedMarkdown.Markdown, MarkdownPipeline); diff --git a/Redot-Documentation/Services/DocumentPathResolver.cs b/Redot-Documentation/Services/DocumentPathResolver.cs new file mode 100644 index 0000000..ad184f7 --- /dev/null +++ b/Redot-Documentation/Services/DocumentPathResolver.cs @@ -0,0 +1,97 @@ +using System.Collections.Frozen; +using Redot_Documentation.Versioning; + +namespace Redot_Documentation.Services; + +/// Maps URL aliases to the exact spelling of bundled Markdown files. +public sealed class DocumentPathResolver +{ + private readonly FrozenDictionary documents; + + private readonly VersionManagerService versions; + + public DocumentPathResolver(IWebHostEnvironment environment, VersionManagerService versions) + { + this.versions = versions; + var root = Path.GetFullPath(Path.Combine(environment.ContentRootPath, "docs")); + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var file in EnumerateMarkdownFiles(root)) + { + var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); + var key = NormalizePath(relative); + var document = new ResolvedDocument(file, relative, PublicUrl(relative)); + if (!entries.TryAdd(key, document)) + throw new InvalidOperationException( + $"Ambiguous documentation paths: '{entries[key].RelativePath}' and '{relative}'."); + } + documents = entries.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } + + internal static IEnumerable EnumerateMarkdownFiles(string root) + { + if (!Directory.Exists(root) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) + return []; + // Share the same file boundary with search indexing, including its fingerprint reads. + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + AttributesToSkip = FileAttributes.ReparsePoint, + IgnoreInaccessible = false + }; + return Directory.EnumerateFiles(root, "*", options) + .Where(file => Path.GetExtension(file).Equals(".md", StringComparison.OrdinalIgnoreCase)); + } + + public ResolvedDocument? Resolve(string path, VersionProvider provider) + { + var key = NormalizePath(path); + var firstSegment = key.Split('/', 2)[0]; + if (!versions.TryGetVersionProvider(firstSegment, out _) + && documents.TryGetValue(key, out var shared)) + return shared; + return documents.GetValueOrDefault($"{provider.Version.Slug}/{key}"); + } + + public ResolvedDocument? ResolveRoute(string path, VersionManagerService versions) + { + var (provider, documentPath) = ParseRoute(path, versions); + return Resolve(documentPath, provider); + } + + public static (VersionProvider Provider, string Path) ParseRoute(string path, VersionManagerService versions) + { + var normalized = path.Trim('/'); + var separator = normalized.IndexOf('/'); + var first = separator < 0 ? normalized : normalized[..separator]; + if (versions.TryGetVersionProvider(first, out var provider)) + return (provider!, separator < 0 ? "" : normalized[(separator + 1)..]); + return (versions.GetVersionProvider(versions.LatestStableVersion.Slug), normalized); + } + + // Input is a decoded route path, never a full URL. Do not decode a second time. + public static string NormalizePath(string path) + { + if (path.Contains('\\') || path.Contains('?') || path.Contains('#') || path.Contains('\0')) + throw new ArgumentException("Invalid documentation path.", nameof(path)); + var normalized = path.Trim('/'); + if (normalized.Split('/').Any(segment => segment is "." or "..")) + throw new ArgumentException("Invalid documentation path.", nameof(path)); + if (normalized.Length == 0) + return "index"; + if (normalized.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) + return normalized[..^5]; + if (normalized.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + return normalized[..^3]; + return normalized; + } + + public static string PublicUrl(string markdownRelativePath) + { + var path = markdownRelativePath.Replace('\\', '/'); + if (path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + path = path[..^3]; + return "/en/" + string.Join('/', path.Split('/').Select(Uri.EscapeDataString)); + } +} + +public sealed record ResolvedDocument(string FullPath, string RelativePath, string PublicUrl); diff --git a/Redot-Documentation/Versioning/VersionProvider.cs b/Redot-Documentation/Versioning/VersionProvider.cs index 9c7217b..101b698 100644 --- a/Redot-Documentation/Versioning/VersionProvider.cs +++ b/Redot-Documentation/Versioning/VersionProvider.cs @@ -113,7 +113,7 @@ private void ParseSlugs(Section section, IDictionary slugLookupT private void AddSlug(IRanking ranking, IDictionary slugLookupTable) { - string path = GetReferentialPath(ranking.Path); + string path = Services.DocumentPathResolver.PublicUrl(Path.GetRelativePath(_docsRootPath, ranking.Path)); if (slugLookupTable.TryGetValue(ranking.Slug, out string? existingPath)) throw new InvalidOperationException( $"Duplicate documentation slug '{ranking.Slug}' in version '{Version.Slug}': '{existingPath}' and '{path}'."); diff --git a/Redot-Documentation/wwwroot/app.css b/Redot-Documentation/wwwroot/app.css index ed8ca1b..add858e 100644 --- a/Redot-Documentation/wwwroot/app.css +++ b/Redot-Documentation/wwwroot/app.css @@ -27,7 +27,8 @@ button, a { -webkit-tap-highlight-color: transparent; } .nav-kicker { font-size: .7rem; letter-spacing: .14em; color: var(--redot-muted); padding: 0 12px; margin: 0 0 14px; } .version-select { margin: 0 8px 20px; } .docs-nav .mud-nav-link { border-radius: 6px; margin: 2px 0; font-size: .85rem; padding-block: 8px; } -.docs-nav .mud-nav-link.active { color: #ff967c; background: #ff3b0a16; box-shadow: inset 2px 0 var(--redot-brand); } +.docs-nav .mud-nav-link.active, +.docs-nav .doc-current > .mud-nav-link { color: #ff967c; background: #ff3b0a16; box-shadow: inset 2px 0 var(--redot-brand); } .docs-nav .mud-nav-link-text { white-space: normal; overflow-wrap: anywhere; } .docs-nav .mud-nav-group .mud-nav-menu { margin-left: 10px; padding-left: 5px; border-left: 1px solid #ffffff0c; } .docs-main, .landing-main { min-height: 100vh; display: flex; flex-direction: column; } From b38ea792e35736870acce17c2cbff4d7814300f4 Mon Sep 17 00:00:00 2001 From: Arctis_Fireblight <6182060+Arctis-Fireblight@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:01:55 -0500 Subject: [PATCH 2/2] Fix merge --- .../DocRendererServiceTests.cs | 7 ++++--- .../DocumentationLinkTests.cs | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Redot-Documentation-Tests/DocRendererServiceTests.cs b/Redot-Documentation-Tests/DocRendererServiceTests.cs index 274dc82..d9d87ed 100644 --- a/Redot-Documentation-Tests/DocRendererServiceTests.cs +++ b/Redot-Documentation-Tests/DocRendererServiceTests.cs @@ -17,7 +17,7 @@ public sealed class DocRendererServiceTests : IDisposable [InlineData("doc_some_doc#some-section", "/en/About/some_doc#some-section")] [InlineData("doc_some_doc#Some%20Section", "/en/About/some_doc#Some%20Section")] [InlineData("doc_some_doc#version-4.1", "/en/About/some_doc#version-4.1")] - [InlineData("doc_some_doc", "/en/About/some_doc.md")] + [InlineData("doc_some_doc", "/en/About/some_doc")] public async Task RenderToHtmlAsync_ResolvesDocumentSlugsWithOptionalSections( string target, string expectedHref) @@ -41,10 +41,11 @@ public async Task RenderToHtmlAsync_PreservesLiteralLinkExamples() Directory.CreateDirectory(Path.Combine(contentRootPath, "docs")); await File.WriteAllTextAsync(Path.Combine(contentRootPath, "docs", "source.md"), "``[example_name](doc_some_doc)``\n\n```markdown\n[example_name](doc_some_doc)\n```"); - var renderer = new DocRendererService(new TestWebHostEnvironment(contentRootPath)); + var renderer = new DocRendererService(new DocumentPathResolver(new TestWebHostEnvironment(contentRootPath), + new VersionManagerService(new TestWebHostEnvironment(contentRootPath)))); var html = await renderer.RenderToHtmlAsync("source.md", CreateVersionProvider()); Assert.Contains("[example_name](doc_some_doc)", html); - Assert.DoesNotContain("/en/About/some_doc.md", html); + Assert.DoesNotContain("/en/About/some_doc", html); } [Fact] diff --git a/Redot-Documentation-Tests/DocumentationLinkTests.cs b/Redot-Documentation-Tests/DocumentationLinkTests.cs index 6c08b0e..fa6bb5b 100644 --- a/Redot-Documentation-Tests/DocumentationLinkTests.cs +++ b/Redot-Documentation-Tests/DocumentationLinkTests.cs @@ -18,20 +18,27 @@ public async Task AllDocumentationLinksResolveToExistingPagesAndSections() var environment = new AuditEnvironment { ContentRootPath = root }; var versions = new VersionManagerService(environment); versions.LoadContent(); - var renderer = new DocRendererService(environment); + var paths = new DocumentPathResolver(environment, versions); + var renderer = new DocRendererService(paths); var pages = new Dictionary<(string Version, string Route), HtmlDocument>(); foreach (var version in versions.Versions) { var provider = versions.GetVersionProvider(version.Slug); - foreach (var file in Directory.EnumerateFiles(Path.Combine(root, "docs"), "*.md", SearchOption.AllDirectories)) + foreach (var file in DocumentPathResolver.EnumerateMarkdownFiles(Path.Combine(root, "docs"))) { var relative = Path.GetRelativePath(Path.Combine(root, "docs"), file).Replace('\\', '/'); if (!relative.StartsWith(version.Slug + "/") && !new[] { "About/", "Community/", "Contributing/" }.Any(relative.StartsWith)) continue; + var resolved = paths.ResolveRoute(relative, versions); + Assert.NotNull(resolved); + Assert.Equal(Path.GetFullPath(file), resolved.FullPath); + // Verify legacy aliases against the merged documentation corpus as well. + foreach (var suffix in new[] { "", ".MD", ".HTML" }) + Assert.Equal(resolved, paths.ResolveRoute(relative[..^3].ToUpperInvariant() + suffix, versions)); var document = new HtmlDocument(); - document.LoadHtml(DocumentHeadings.Apply(await renderer.RenderToHtmlAsync(relative, provider), [])); - pages.Add((version.Slug, "/en/" + relative[..^3]), document); + document.LoadHtml(DocumentHeadings.Apply(await renderer.RenderToHtmlAsync(resolved, provider), [])); + pages.Add((version.Slug, resolved.PublicUrl), document); } } @@ -45,10 +52,11 @@ public async Task AllDocumentationLinksResolveToExistingPagesAndSections() continue; var target = new Uri(new Uri("https://audit.invalid" + key.Route), href); var path = Uri.UnescapeDataString(target.AbsolutePath); - if (path.EndsWith(".md")) path = path[..^3]; // Class references come from separately synchronized engine XML, not this documentation corpus. if (path.Split('/').Contains("Classes") || !path.StartsWith("/en/")) continue; + var resolved = paths.ResolveRoute(path[4..], versions); + path = resolved?.PublicUrl ?? path; if (!pages.TryGetValue((key.Version, path), out var destination)) destination = pages.FirstOrDefault(p => p.Key.Route == path).Value; var fragment = Uri.UnescapeDataString(target.Fragment.TrimStart('#'));