From afdf5db3caadb451c6f59e41a56e8bf88dfeea26 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 11 Nov 2025 19:53:17 -0300 Subject: [PATCH 01/11] Fix image path definition --- .../Myst/InlineParsers/DiagnosticLinkInlineParser.cs | 11 +++++++++++ tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs | 10 +++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index f73721d42..e0e704934 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -391,11 +391,22 @@ public static string UpdateRelativeUrl(ParserContext context, string url) newUrl = newUrl[3..]; offset--; } + newUrl = Path.GetFullPath(Path.Combine(urlPathPrefix, snippet.RelativeFolder, url)) + .OptionalWindowsReplace().TrimStart('/'); } else newUrl = $"/{Path.Combine(urlPathPrefix, relativePath).OptionalWindowsReplace().TrimStart('/')}"; + } + // CrossLinkResolver gives the navigation-aware path to the URI + if (context.TryFindDocument(fi) is MarkdownFile currentMarkdown && context.CrossLinkResolver.TryResolve((err) => context.EmitError(err), new Uri(currentMarkdown.CrossLink), out var resolvedUri)) + { + if (resolvedUri.AbsolutePath.LastIndexOf('/') > 0) + newUrl = Path.GetFullPath(Path.Combine(resolvedUri.AbsolutePath[..resolvedUri.AbsolutePath.LastIndexOf('/')], url)); + newUrl = $"/{Path.Combine(newUrl.StartsWith(urlPathPrefix) ? string.Empty : urlPathPrefix, newUrl.TrimStart('/')) + .OptionalWindowsReplace().TrimStart('/')}"; } + // When running on Windows, path traversal results must be normalized prior to being used in a URL // Path.GetFullPath() will result in the drive letter being appended to the path, which needs to be pruned back. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index 3df2faf91..fe48782f3 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions.TestingHelpers; using System.Runtime.InteropServices; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Markdown.IO; using FluentAssertions; @@ -113,7 +114,14 @@ protected InlineTest( Collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); - var context = new BuildContext(Collector, FileSystem, configurationContext) + var gitInfo = new GitCheckoutInformation + { + Branch = "main", + Remote = "elastic/docs-content", + Ref = "main", + RepositoryName = "docs-content" + }; + var context = new BuildContext(Collector, FileSystem, FileSystem, configurationContext, ExportOptions.Default, null, null, gitInfo) { UrlPathPrefix = "/docs" }; From 3d1e396e268e29ff517c1e52ea1843ec4b001e53 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 11 Nov 2025 20:20:32 -0300 Subject: [PATCH 02/11] Only use CrossLink navigation data on assembler builds --- .../Myst/InlineParsers/DiagnosticLinkInlineParser.cs | 2 +- tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index e0e704934..9deb0011b 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -399,7 +399,7 @@ public static string UpdateRelativeUrl(ParserContext context, string url) } // CrossLinkResolver gives the navigation-aware path to the URI - if (context.TryFindDocument(fi) is MarkdownFile currentMarkdown && context.CrossLinkResolver.TryResolve((err) => context.EmitError(err), new Uri(currentMarkdown.CrossLink), out var resolvedUri)) + if (context.Build.AssemblerBuild && context.TryFindDocument(fi) is MarkdownFile currentMarkdown && context.CrossLinkResolver.TryResolve((err) => context.EmitError(err), new Uri(currentMarkdown.CrossLink), out var resolvedUri)) { if (resolvedUri.AbsolutePath.LastIndexOf('/') > 0) newUrl = Path.GetFullPath(Path.Combine(resolvedUri.AbsolutePath[..resolvedUri.AbsolutePath.LastIndexOf('/')], url)); diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index fe48782f3..3df2faf91 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions.TestingHelpers; using System.Runtime.InteropServices; -using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Markdown.IO; using FluentAssertions; @@ -114,14 +113,7 @@ protected InlineTest( Collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); - var gitInfo = new GitCheckoutInformation - { - Branch = "main", - Remote = "elastic/docs-content", - Ref = "main", - RepositoryName = "docs-content" - }; - var context = new BuildContext(Collector, FileSystem, FileSystem, configurationContext, ExportOptions.Default, null, null, gitInfo) + var context = new BuildContext(Collector, FileSystem, configurationContext) { UrlPathPrefix = "/docs" }; From a013b0984ef87053b1d98cbb1715e11e47179d2d Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 11 Nov 2025 20:40:21 -0300 Subject: [PATCH 03/11] Fix acquisition method --- .../InlineParsers/DiagnosticLinkInlineParser.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index 9deb0011b..2fabc1d10 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -398,11 +398,18 @@ public static string UpdateRelativeUrl(ParserContext context, string url) newUrl = $"/{Path.Combine(urlPathPrefix, relativePath).OptionalWindowsReplace().TrimStart('/')}"; } - // CrossLinkResolver gives the navigation-aware path to the URI - if (context.Build.AssemblerBuild && context.TryFindDocument(fi) is MarkdownFile currentMarkdown && context.CrossLinkResolver.TryResolve((err) => context.EmitError(err), new Uri(currentMarkdown.CrossLink), out var resolvedUri)) + if (context.Build.AssemblerBuild && context.TryFindDocument(fi) is MarkdownFile currentMarkdown) { - if (resolvedUri.AbsolutePath.LastIndexOf('/') > 0) - newUrl = Path.GetFullPath(Path.Combine(resolvedUri.AbsolutePath[..resolvedUri.AbsolutePath.LastIndexOf('/')], url)); + // Acquire navigation-aware path + if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(currentMarkdown, out var currentNavigation) && !string.IsNullOrEmpty(currentNavigation.Url)) + { + var currentUrl = currentNavigation.Url; + if (currentUrl.LastIndexOf('/') > 0) + { + var basePath = currentUrl[..currentUrl.LastIndexOf('/')]; + newUrl = Path.GetFullPath(Path.Combine(basePath, url)); + } + } newUrl = $"/{Path.Combine(newUrl.StartsWith(urlPathPrefix) ? string.Empty : urlPathPrefix, newUrl.TrimStart('/')) .OptionalWindowsReplace().TrimStart('/')}"; } From ed254660fc280c8f89889e0e374fb81a90ac29a1 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 12 Nov 2025 10:30:31 -0300 Subject: [PATCH 04/11] Apply suggestion from @Mpdreamz Co-authored-by: Martijn Laarman --- .../Myst/InlineParsers/DiagnosticLinkInlineParser.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index 2fabc1d10..8e1ea7902 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -410,6 +410,9 @@ public static string UpdateRelativeUrl(ParserContext context, string url) newUrl = Path.GetFullPath(Path.Combine(basePath, url)); } } + else + context.EmitError($"Failed to acquire navigation for current markdown file '{currentMarkdown.FileName}' while resolving relative url '{url}'."); + newUrl = $"/{Path.Combine(newUrl.StartsWith(urlPathPrefix) ? string.Empty : urlPathPrefix, newUrl.TrimStart('/')) .OptionalWindowsReplace().TrimStart('/')}"; } From 61a683942080793de003085a8eb827be84e94071 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 13 Nov 2025 10:02:20 +0100 Subject: [PATCH 05/11] Add image path tests --- .../Inline/AssemblerImagePathTests.cs | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 tests/Elastic.Markdown.Tests/Inline/AssemblerImagePathTests.cs diff --git a/tests/Elastic.Markdown.Tests/Inline/AssemblerImagePathTests.cs b/tests/Elastic.Markdown.Tests/Inline/AssemblerImagePathTests.cs new file mode 100644 index 000000000..b01b65a20 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Inline/AssemblerImagePathTests.cs @@ -0,0 +1,206 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using System.Threading.Tasks; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Navigation; +using Elastic.Markdown.IO; +using Elastic.Markdown.Myst; +using Elastic.Markdown.Myst.InlineParsers; +using Elastic.Markdown.Tests; +using FluentAssertions; +using Xunit; + +namespace Elastic.Markdown.Tests.Inline; + +public class AssemblerImagePathTests(ITestOutputHelper output) +{ + [Fact] + public async Task UpdateRelativeUrlUsesNavigationPathWhenAssemblerBuildEnabled() + { + const string relativeAssetPath = "images/pic.png"; + var nonAssemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: false, pathPrefix: "this-is-not-relevant"); + var assemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: true, pathPrefix: "platform"); + + nonAssemblerResult.Should().Be("/docs/setup/images/pic.png"); + assemblerResult.Should().Be("/docs/platform/setup/images/pic.png"); + } + + [Fact] + public async Task UpdateRelativeUrlWithoutPathPrefixKeepsGlobalPrefix() + { + var relativeAssetPath = "images/funny-image.png"; + var assemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: true, pathPrefix: null); + + assemblerResult.Should().Be("/docs/setup/images/funny-image.png"); + } + + [Fact] + public async Task UpdateRelativeUrlAppliesCustomPathPrefix() + { + var relativeAssetPath = "images/image.png"; + var assemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: true, pathPrefix: "custom"); + + assemblerResult.Should().Be("/docs/custom/setup/images/image.png"); + } + + /// + /// Resolves a relative asset URL the same way the assembler would for a single markdown file, using the provided navigation path prefix. + /// + private async Task ResolveUrlForBuildMode(string relativeAssetPath, bool assemblerBuild, string? pathPrefix) + { + const string guideRelativePath = "setup/guide.md"; + var navigationUrl = BuildNavigationUrl(pathPrefix, guideRelativePath); + var files = new Dictionary + { + ["docs/docset.yml"] = new( + $""" + project: test + toc: + - file: index.md + - file: {guideRelativePath} + """ + ), + ["docs/index.md"] = new("# Home"), + ["docs/" + guideRelativePath] = new( + $""" + # Guide + + ![Alt]({relativeAssetPath}) + """ + ), + ["docs/setup/" + relativeAssetPath] = new([]) + }; + + var fileSystem = new MockFileSystem(files, new MockFileSystemOptions + { + CurrentDirectory = Paths.WorkingDirectoryRoot.FullName + }); + + var collector = new TestDiagnosticsCollector(output); + _ = collector.StartAsync(TestContext.Current.CancellationToken); + + var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); + var buildContext = new BuildContext(collector, fileSystem, configurationContext) + { + UrlPathPrefix = "/docs", + AssemblerBuild = assemblerBuild + }; + + var documentationSet = new DocumentationSet(buildContext, new TestLoggerFactory(output), new TestCrossLinkResolver()); + + await documentationSet.ResolveDirectoryTree(TestContext.Current.CancellationToken); + + if (documentationSet.TryFindDocumentByRelativePath(guideRelativePath) is not MarkdownFile markdownFile) + throw new InvalidOperationException("Failed to resolve markdown file for test."); + + // For assembler builds DocumentationSetNavigation seeds MarkdownNavigationLookup with navigation items whose Url already + // includes the computed path_prefix. To exercise the same branch in isolation, inject a stub navigation entry with the + // expected Url (and minimal metadata for the surrounding API contract). + _ = documentationSet.MarkdownNavigationLookup.Remove(markdownFile); + documentationSet.MarkdownNavigationLookup.Add(markdownFile, new NavigationItemStub(navigationUrl)); + documentationSet.MarkdownNavigationLookup.TryGetValue(markdownFile, out var navigation).Should() + .BeTrue("navigation lookup should contain current page"); + navigation?.Url.Should().Be(navigationUrl); + + var parserState = new ParserState(buildContext) + { + MarkdownSourcePath = markdownFile.SourceFile, + YamlFrontMatter = null, + CrossLinkResolver = documentationSet.CrossLinkResolver, + TryFindDocument = file => documentationSet.TryFindDocument(file), + TryFindDocumentByRelativePath = path => documentationSet.TryFindDocumentByRelativePath(path), + PositionalNavigation = documentationSet + }; + + var context = new ParserContext(parserState); + context.TryFindDocument(context.MarkdownSourcePath).Should().BeSameAs(markdownFile); + context.Build.AssemblerBuild.Should().Be(assemblerBuild); + + var resolved = DiagnosticLinkInlineParser.UpdateRelativeUrl(context, relativeAssetPath); + + await collector.StopAsync(TestContext.Current.CancellationToken); + + return resolved; + } + + /// + /// Helper that mirrors the assembler's path-prefix handling in DocumentationSetNavigation: + /// combines the relative path_prefix from navigation.yml with the markdown path (stripped of ".md") so our stub + /// navigation item carries the same Url the production code would have provided. + /// + private static string BuildNavigationUrl(string? pathPrefix, string docRelativePath) + { + var docPath = docRelativePath.Replace('\\', '/').Trim('/'); + if (docPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + docPath = docPath[..^3]; + + var segments = new List(); + if (!string.IsNullOrWhiteSpace(pathPrefix)) + segments.Add(pathPrefix.Trim('/')); + if (!string.IsNullOrWhiteSpace(docPath)) + segments.Add(docPath); + + var combined = string.Join('/', segments); + return "/" + combined.Trim('/'); + } + + /// + /// Minimal navigation stub so UpdateRelativeUrl can rely on navigation metadata without constructing the full site navigation tree. + /// + private sealed class NavigationItemStub(string url) : INavigationItem + { + private sealed class NavigationModelStub : INavigationModel + { + } + + /// + /// Simplified root navigation item to satisfy the IRootNavigationItem contract. + /// + private sealed class RootNavigationItemStub : IRootNavigationItem + { + /// + /// Leaf implementation used by the root stub. Navigation requires both root and leaf nodes present. + /// + private sealed class LeafNavigationItemStub(RootNavigationItemStub root) : ILeafNavigationItem + { + public string Url => "/"; + public string NavigationTitle => "Root"; + public IRootNavigationItem NavigationRoot { get; } = root; + public INodeNavigationItem? Parent { get; set; } + public bool Hidden => false; + public int NavigationIndex { get; set; } + public INavigationModel Model { get; } = new NavigationModelStub(); + } + + public RootNavigationItemStub() => Index = new LeafNavigationItemStub(this); + + public string Url => "/"; + public string NavigationTitle => "Root"; + public IRootNavigationItem NavigationRoot => this; + public INodeNavigationItem? Parent { get; set; } + public bool Hidden => false; + public int NavigationIndex { get; set; } + public string Id => "root"; + public ILeafNavigationItem Index { get; } + public IReadOnlyCollection NavigationItems { get; private set; } = []; + public bool IsUsingNavigationDropdown => false; + public Uri Identifier => new("https://example.test/"); + public void SetNavigationItems(IReadOnlyCollection navigationItems) => NavigationItems = navigationItems; + } + + private static readonly RootNavigationItemStub Root = new(); + + public string Url { get; } = url; + public string NavigationTitle => "Stub"; + public IRootNavigationItem NavigationRoot => Root; + public INodeNavigationItem? Parent { get; set; } + public bool Hidden => false; + public int NavigationIndex { get; set; } + } +} From 11719793192dab6d33d200d5cd07314e5b14bf18 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 13 Nov 2025 10:31:42 +0100 Subject: [PATCH 06/11] rename --- .../{AssemblerImagePathTests.cs => ImagePathResolutionTests.cs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/Elastic.Markdown.Tests/Inline/{AssemblerImagePathTests.cs => ImagePathResolutionTests.cs} (99%) diff --git a/tests/Elastic.Markdown.Tests/Inline/AssemblerImagePathTests.cs b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs similarity index 99% rename from tests/Elastic.Markdown.Tests/Inline/AssemblerImagePathTests.cs rename to tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs index b01b65a20..eef9ba313 100644 --- a/tests/Elastic.Markdown.Tests/Inline/AssemblerImagePathTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs @@ -18,7 +18,7 @@ namespace Elastic.Markdown.Tests.Inline; -public class AssemblerImagePathTests(ITestOutputHelper output) +public class ImagePathResolutionTests(ITestOutputHelper output) { [Fact] public async Task UpdateRelativeUrlUsesNavigationPathWhenAssemblerBuildEnabled() From 945d578ca66c391fdcace93d12b835b5cfec44cd Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 13 Nov 2025 14:49:07 +0100 Subject: [PATCH 07/11] Fix test on windows --- .../Inline/ImagePathResolutionTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs index eef9ba313..6ee67d23c 100644 --- a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs @@ -96,8 +96,10 @@ private async Task ResolveUrlForBuildMode(string relativeAssetPath, bool await documentationSet.ResolveDirectoryTree(TestContext.Current.CancellationToken); - if (documentationSet.TryFindDocumentByRelativePath(guideRelativePath) is not MarkdownFile markdownFile) - throw new InvalidOperationException("Failed to resolve markdown file for test."); + // Normalize path for cross-platform compatibility (Windows uses backslashes) + var normalizedPath = guideRelativePath.Replace('/', Path.DirectorySeparatorChar); + if (documentationSet.TryFindDocumentByRelativePath(normalizedPath) is not MarkdownFile markdownFile) + throw new InvalidOperationException($"Failed to resolve markdown file for test. Tried path: {normalizedPath}"); // For assembler builds DocumentationSetNavigation seeds MarkdownNavigationLookup with navigation items whose Url already // includes the computed path_prefix. To exercise the same branch in isolation, inject a stub navigation entry with the From 68a4df25548259027b3d38bdaddea817cfce3434 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 13 Nov 2025 11:10:16 -0300 Subject: [PATCH 08/11] Add UrlCombine() to be used in lieu of Path.Combine() for URL path resolving (Copilot) Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../InlineParsers/DiagnosticLinkInlineParser.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index 8e1ea7902..07af2c049 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -391,8 +391,8 @@ public static string UpdateRelativeUrl(ParserContext context, string url) newUrl = newUrl[3..]; offset--; } - newUrl = Path.GetFullPath(Path.Combine(urlPathPrefix, snippet.RelativeFolder, url)) - .OptionalWindowsReplace().TrimStart('/'); + newUrl = UrlCombine(urlPathPrefix, snippet.RelativeFolder, url) + .TrimStart('/'); } else newUrl = $"/{Path.Combine(urlPathPrefix, relativePath).OptionalWindowsReplace().TrimStart('/')}"; @@ -435,4 +435,16 @@ public static string UpdateRelativeUrl(ParserContext context, string url) private static bool IsCrossLink([NotNullWhen(true)] Uri? uri) => CrossLinkValidator.IsCrossLink(uri); + /// + /// Joins URL path segments ensuring exactly one '/' between parts and no double slashes. + /// + private static string UrlCombine(params string[] parts) + { + if (parts == null || parts.Length == 0) return string.Empty; + // Remove any leading/trailing slashes and join + return string.Join("/", parts + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim('/')) + ); + } } From 72507685fa264209530153e2d7bfb19e786a5982 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 13 Nov 2025 11:13:08 -0300 Subject: [PATCH 09/11] Lint suggestion --- .../Myst/InlineParsers/DiagnosticLinkInlineParser.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index 07af2c049..e0f177818 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -438,9 +438,10 @@ private static bool IsCrossLink([NotNullWhen(true)] Uri? uri) => /// /// Joins URL path segments ensuring exactly one '/' between parts and no double slashes. /// - private static string UrlCombine(params string[] parts) + private static string UrlCombine(params string[]? parts) { - if (parts == null || parts.Length == 0) return string.Empty; + if (parts == null || parts.Length == 0) + return string.Empty; // Remove any leading/trailing slashes and join return string.Join("/", parts .Where(p => !string.IsNullOrWhiteSpace(p)) From 9cb0b796fdb7e9d6a96012a2d98a9e9910a69d23 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 13 Nov 2025 12:29:06 -0300 Subject: [PATCH 10/11] Simplify path generation with UriBuilder --- .../DiagnosticLinkInlineParser.cs | 38 +++++-------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index e0f177818..18d0ca125 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -359,7 +359,8 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s // on `DocumentationFile` that are mostly precomputed public static string UpdateRelativeUrl(ParserContext context, string url) { - var urlPathPrefix = context.Build.UrlPathPrefix ?? string.Empty; + var urlPathPrefix = !string.IsNullOrWhiteSpace(context.Build.UrlPathPrefix) ? context.Build.UrlPathPrefix : "/"; + var baseUri = new UriBuilder("http", "localhost", 80, urlPathPrefix[^1] != '/' ? $"{urlPathPrefix}/" : urlPathPrefix).Uri; var fi = context.MarkdownSourcePath; @@ -391,34 +392,26 @@ public static string UpdateRelativeUrl(ParserContext context, string url) newUrl = newUrl[3..]; offset--; } - newUrl = UrlCombine(urlPathPrefix, snippet.RelativeFolder, url) - .TrimStart('/'); + + newUrl = new Uri(baseUri, Path.Combine(snippet.RelativeFolder, url).OptionalWindowsReplace()).AbsolutePath; } else - newUrl = $"/{Path.Combine(urlPathPrefix, relativePath).OptionalWindowsReplace().TrimStart('/')}"; + newUrl = new Uri(baseUri, relativePath).AbsolutePath; } if (context.Build.AssemblerBuild && context.TryFindDocument(fi) is MarkdownFile currentMarkdown) { // Acquire navigation-aware path - if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(currentMarkdown, out var currentNavigation) && !string.IsNullOrEmpty(currentNavigation.Url)) + if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(currentMarkdown, out var currentNavigation)) { - var currentUrl = currentNavigation.Url; - if (currentUrl.LastIndexOf('/') > 0) - { - var basePath = currentUrl[..currentUrl.LastIndexOf('/')]; - newUrl = Path.GetFullPath(Path.Combine(basePath, url)); - } + var uri = new Uri(new UriBuilder("http", "localhost", 80, currentNavigation.Url).Uri, url); + newUrl = uri.AbsolutePath; } else context.EmitError($"Failed to acquire navigation for current markdown file '{currentMarkdown.FileName}' while resolving relative url '{url}'."); - - newUrl = $"/{Path.Combine(newUrl.StartsWith(urlPathPrefix) ? string.Empty : urlPathPrefix, newUrl.TrimStart('/')) - .OptionalWindowsReplace().TrimStart('/')}"; } // When running on Windows, path traversal results must be normalized prior to being used in a URL - // Path.GetFullPath() will result in the drive letter being appended to the path, which needs to be pruned back. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { newUrl = newUrl.Replace('\\', '/'); @@ -427,7 +420,7 @@ public static string UpdateRelativeUrl(ParserContext context, string url) } if (!string.IsNullOrWhiteSpace(newUrl) && !string.IsNullOrWhiteSpace(urlPathPrefix) && !newUrl.StartsWith(urlPathPrefix)) - newUrl = $"{urlPathPrefix.TrimEnd('/')}{newUrl}"; + newUrl = new Uri(baseUri, newUrl.TrimStart('/')).AbsolutePath; // eat overall path prefix since its gets appended later return newUrl; @@ -435,17 +428,4 @@ public static string UpdateRelativeUrl(ParserContext context, string url) private static bool IsCrossLink([NotNullWhen(true)] Uri? uri) => CrossLinkValidator.IsCrossLink(uri); - /// - /// Joins URL path segments ensuring exactly one '/' between parts and no double slashes. - /// - private static string UrlCombine(params string[]? parts) - { - if (parts == null || parts.Length == 0) - return string.Empty; - // Remove any leading/trailing slashes and join - return string.Join("/", parts - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Select(p => p.Trim('/')) - ); - } } From b8dfe2208e1179af7e6cbb1fc7fd7a2b7aced7a5 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 13 Nov 2025 12:36:25 -0300 Subject: [PATCH 11/11] Remove Path.Combine() Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../Myst/InlineParsers/DiagnosticLinkInlineParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index 18d0ca125..142d889d4 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -393,7 +393,7 @@ public static string UpdateRelativeUrl(ParserContext context, string url) offset--; } - newUrl = new Uri(baseUri, Path.Combine(snippet.RelativeFolder, url).OptionalWindowsReplace()).AbsolutePath; + newUrl = new Uri(baseUri, $"{snippet.RelativeFolder.TrimEnd('/')}/{url.TrimStart('/')}").AbsolutePath; } else newUrl = new Uri(baseUri, relativePath).AbsolutePath;