Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -391,13 +392,26 @@ public static string UpdateRelativeUrl(ParserContext context, string url)
newUrl = newUrl[3..];
offset--;
}

newUrl = new Uri(baseUri, $"{snippet.RelativeFolder.TrimEnd('/')}/{url.TrimStart('/')}").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))
{
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}'.");
}

// 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('\\', '/');
Expand All @@ -406,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;
Expand Down
208 changes: 208 additions & 0 deletions tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// 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 ImagePathResolutionTests(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");
}

/// <summary>
/// Resolves a relative asset URL the same way the assembler would for a single markdown file, using the provided navigation path prefix.
/// </summary>
private async Task<string> ResolveUrlForBuildMode(string relativeAssetPath, bool assemblerBuild, string? pathPrefix)
{
const string guideRelativePath = "setup/guide.md";
var navigationUrl = BuildNavigationUrl(pathPrefix, guideRelativePath);
var files = new Dictionary<string, MockFileData>
{
["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);

// 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
// 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;
}

/// <summary>
/// Helper that mirrors the assembler's path-prefix handling in <c>DocumentationSetNavigation</c>:
/// combines the relative <c>path_prefix</c> 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.
/// </summary>
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<string>();
if (!string.IsNullOrWhiteSpace(pathPrefix))
segments.Add(pathPrefix.Trim('/'));
if (!string.IsNullOrWhiteSpace(docPath))
segments.Add(docPath);

var combined = string.Join('/', segments);
return "/" + combined.Trim('/');
}

/// <summary>
/// Minimal navigation stub so UpdateRelativeUrl can rely on navigation metadata without constructing the full site navigation tree.
/// </summary>
private sealed class NavigationItemStub(string url) : INavigationItem
{
private sealed class NavigationModelStub : INavigationModel
{
}

/// <summary>
/// Simplified root navigation item to satisfy the IRootNavigationItem contract.
/// </summary>
private sealed class RootNavigationItemStub : IRootNavigationItem<INavigationModel, INavigationItem>
{
/// <summary>
/// Leaf implementation used by the root stub. Navigation requires both root and leaf nodes present.
/// </summary>
private sealed class LeafNavigationItemStub(RootNavigationItemStub root) : ILeafNavigationItem<INavigationModel>
{
public string Url => "/";
public string NavigationTitle => "Root";
public IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot { get; } = root;
public INodeNavigationItem<INavigationModel, INavigationItem>? 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<INavigationModel, INavigationItem> NavigationRoot => this;
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }
public bool Hidden => false;
public int NavigationIndex { get; set; }
public string Id => "root";
public ILeafNavigationItem<INavigationModel> Index { get; }
public IReadOnlyCollection<INavigationItem> NavigationItems { get; private set; } = [];
public bool IsUsingNavigationDropdown => false;
public Uri Identifier => new("https://example.test/");
public void SetNavigationItems(IReadOnlyCollection<INavigationItem> navigationItems) => NavigationItems = navigationItems;
}

private static readonly RootNavigationItemStub Root = new();

public string Url { get; } = url;
public string NavigationTitle => "Stub";
public IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot => Root;
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }
public bool Hidden => false;
public int NavigationIndex { get; set; }
}
}
Loading