Skip to content

Commit 0df0d25

Browse files
authored
Fix image URLs (#2193)
* Fix image path definition
1 parent 9e1dee4 commit 0df0d25

File tree

2 files changed

+226
-4
lines changed

2 files changed

+226
-4
lines changed

src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,8 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s
359359
// on `DocumentationFile` that are mostly precomputed
360360
public static string UpdateRelativeUrl(ParserContext context, string url)
361361
{
362-
var urlPathPrefix = context.Build.UrlPathPrefix ?? string.Empty;
362+
var urlPathPrefix = !string.IsNullOrWhiteSpace(context.Build.UrlPathPrefix) ? context.Build.UrlPathPrefix : "/";
363+
var baseUri = new UriBuilder("http", "localhost", 80, urlPathPrefix[^1] != '/' ? $"{urlPathPrefix}/" : urlPathPrefix).Uri;
363364

364365
var fi = context.MarkdownSourcePath;
365366

@@ -391,13 +392,26 @@ public static string UpdateRelativeUrl(ParserContext context, string url)
391392
newUrl = newUrl[3..];
392393
offset--;
393394
}
395+
396+
newUrl = new Uri(baseUri, $"{snippet.RelativeFolder.TrimEnd('/')}/{url.TrimStart('/')}").AbsolutePath;
394397
}
395398
else
396-
newUrl = $"/{Path.Combine(urlPathPrefix, relativePath).OptionalWindowsReplace().TrimStart('/')}";
399+
newUrl = new Uri(baseUri, relativePath).AbsolutePath;
400+
}
397401

402+
if (context.Build.AssemblerBuild && context.TryFindDocument(fi) is MarkdownFile currentMarkdown)
403+
{
404+
// Acquire navigation-aware path
405+
if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(currentMarkdown, out var currentNavigation))
406+
{
407+
var uri = new Uri(new UriBuilder("http", "localhost", 80, currentNavigation.Url).Uri, url);
408+
newUrl = uri.AbsolutePath;
409+
}
410+
else
411+
context.EmitError($"Failed to acquire navigation for current markdown file '{currentMarkdown.FileName}' while resolving relative url '{url}'.");
398412
}
413+
399414
// When running on Windows, path traversal results must be normalized prior to being used in a URL
400-
// Path.GetFullPath() will result in the drive letter being appended to the path, which needs to be pruned back.
401415
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
402416
{
403417
newUrl = newUrl.Replace('\\', '/');
@@ -406,7 +420,7 @@ public static string UpdateRelativeUrl(ParserContext context, string url)
406420
}
407421

408422
if (!string.IsNullOrWhiteSpace(newUrl) && !string.IsNullOrWhiteSpace(urlPathPrefix) && !newUrl.StartsWith(urlPathPrefix))
409-
newUrl = $"{urlPathPrefix.TrimEnd('/')}{newUrl}";
423+
newUrl = new Uri(baseUri, newUrl.TrimStart('/')).AbsolutePath;
410424

411425
// eat overall path prefix since its gets appended later
412426
return newUrl;
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.IO;
8+
using System.IO.Abstractions.TestingHelpers;
9+
using System.Threading.Tasks;
10+
using Elastic.Documentation.Configuration;
11+
using Elastic.Documentation.Navigation;
12+
using Elastic.Markdown.IO;
13+
using Elastic.Markdown.Myst;
14+
using Elastic.Markdown.Myst.InlineParsers;
15+
using Elastic.Markdown.Tests;
16+
using FluentAssertions;
17+
using Xunit;
18+
19+
namespace Elastic.Markdown.Tests.Inline;
20+
21+
public class ImagePathResolutionTests(ITestOutputHelper output)
22+
{
23+
[Fact]
24+
public async Task UpdateRelativeUrlUsesNavigationPathWhenAssemblerBuildEnabled()
25+
{
26+
const string relativeAssetPath = "images/pic.png";
27+
var nonAssemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: false, pathPrefix: "this-is-not-relevant");
28+
var assemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: true, pathPrefix: "platform");
29+
30+
nonAssemblerResult.Should().Be("/docs/setup/images/pic.png");
31+
assemblerResult.Should().Be("/docs/platform/setup/images/pic.png");
32+
}
33+
34+
[Fact]
35+
public async Task UpdateRelativeUrlWithoutPathPrefixKeepsGlobalPrefix()
36+
{
37+
var relativeAssetPath = "images/funny-image.png";
38+
var assemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: true, pathPrefix: null);
39+
40+
assemblerResult.Should().Be("/docs/setup/images/funny-image.png");
41+
}
42+
43+
[Fact]
44+
public async Task UpdateRelativeUrlAppliesCustomPathPrefix()
45+
{
46+
var relativeAssetPath = "images/image.png";
47+
var assemblerResult = await ResolveUrlForBuildMode(relativeAssetPath, assemblerBuild: true, pathPrefix: "custom");
48+
49+
assemblerResult.Should().Be("/docs/custom/setup/images/image.png");
50+
}
51+
52+
/// <summary>
53+
/// Resolves a relative asset URL the same way the assembler would for a single markdown file, using the provided navigation path prefix.
54+
/// </summary>
55+
private async Task<string> ResolveUrlForBuildMode(string relativeAssetPath, bool assemblerBuild, string? pathPrefix)
56+
{
57+
const string guideRelativePath = "setup/guide.md";
58+
var navigationUrl = BuildNavigationUrl(pathPrefix, guideRelativePath);
59+
var files = new Dictionary<string, MockFileData>
60+
{
61+
["docs/docset.yml"] = new(
62+
$"""
63+
project: test
64+
toc:
65+
- file: index.md
66+
- file: {guideRelativePath}
67+
"""
68+
),
69+
["docs/index.md"] = new("# Home"),
70+
["docs/" + guideRelativePath] = new(
71+
$"""
72+
# Guide
73+
74+
![Alt]({relativeAssetPath})
75+
"""
76+
),
77+
["docs/setup/" + relativeAssetPath] = new([])
78+
};
79+
80+
var fileSystem = new MockFileSystem(files, new MockFileSystemOptions
81+
{
82+
CurrentDirectory = Paths.WorkingDirectoryRoot.FullName
83+
});
84+
85+
var collector = new TestDiagnosticsCollector(output);
86+
_ = collector.StartAsync(TestContext.Current.CancellationToken);
87+
88+
var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem);
89+
var buildContext = new BuildContext(collector, fileSystem, configurationContext)
90+
{
91+
UrlPathPrefix = "/docs",
92+
AssemblerBuild = assemblerBuild
93+
};
94+
95+
var documentationSet = new DocumentationSet(buildContext, new TestLoggerFactory(output), new TestCrossLinkResolver());
96+
97+
await documentationSet.ResolveDirectoryTree(TestContext.Current.CancellationToken);
98+
99+
// Normalize path for cross-platform compatibility (Windows uses backslashes)
100+
var normalizedPath = guideRelativePath.Replace('/', Path.DirectorySeparatorChar);
101+
if (documentationSet.TryFindDocumentByRelativePath(normalizedPath) is not MarkdownFile markdownFile)
102+
throw new InvalidOperationException($"Failed to resolve markdown file for test. Tried path: {normalizedPath}");
103+
104+
// For assembler builds DocumentationSetNavigation seeds MarkdownNavigationLookup with navigation items whose Url already
105+
// includes the computed path_prefix. To exercise the same branch in isolation, inject a stub navigation entry with the
106+
// expected Url (and minimal metadata for the surrounding API contract).
107+
_ = documentationSet.MarkdownNavigationLookup.Remove(markdownFile);
108+
documentationSet.MarkdownNavigationLookup.Add(markdownFile, new NavigationItemStub(navigationUrl));
109+
documentationSet.MarkdownNavigationLookup.TryGetValue(markdownFile, out var navigation).Should()
110+
.BeTrue("navigation lookup should contain current page");
111+
navigation?.Url.Should().Be(navigationUrl);
112+
113+
var parserState = new ParserState(buildContext)
114+
{
115+
MarkdownSourcePath = markdownFile.SourceFile,
116+
YamlFrontMatter = null,
117+
CrossLinkResolver = documentationSet.CrossLinkResolver,
118+
TryFindDocument = file => documentationSet.TryFindDocument(file),
119+
TryFindDocumentByRelativePath = path => documentationSet.TryFindDocumentByRelativePath(path),
120+
PositionalNavigation = documentationSet
121+
};
122+
123+
var context = new ParserContext(parserState);
124+
context.TryFindDocument(context.MarkdownSourcePath).Should().BeSameAs(markdownFile);
125+
context.Build.AssemblerBuild.Should().Be(assemblerBuild);
126+
127+
var resolved = DiagnosticLinkInlineParser.UpdateRelativeUrl(context, relativeAssetPath);
128+
129+
await collector.StopAsync(TestContext.Current.CancellationToken);
130+
131+
return resolved;
132+
}
133+
134+
/// <summary>
135+
/// Helper that mirrors the assembler's path-prefix handling in <c>DocumentationSetNavigation</c>:
136+
/// combines the relative <c>path_prefix</c> from navigation.yml with the markdown path (stripped of ".md") so our stub
137+
/// navigation item carries the same Url the production code would have provided.
138+
/// </summary>
139+
private static string BuildNavigationUrl(string? pathPrefix, string docRelativePath)
140+
{
141+
var docPath = docRelativePath.Replace('\\', '/').Trim('/');
142+
if (docPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
143+
docPath = docPath[..^3];
144+
145+
var segments = new List<string>();
146+
if (!string.IsNullOrWhiteSpace(pathPrefix))
147+
segments.Add(pathPrefix.Trim('/'));
148+
if (!string.IsNullOrWhiteSpace(docPath))
149+
segments.Add(docPath);
150+
151+
var combined = string.Join('/', segments);
152+
return "/" + combined.Trim('/');
153+
}
154+
155+
/// <summary>
156+
/// Minimal navigation stub so UpdateRelativeUrl can rely on navigation metadata without constructing the full site navigation tree.
157+
/// </summary>
158+
private sealed class NavigationItemStub(string url) : INavigationItem
159+
{
160+
private sealed class NavigationModelStub : INavigationModel
161+
{
162+
}
163+
164+
/// <summary>
165+
/// Simplified root navigation item to satisfy the IRootNavigationItem contract.
166+
/// </summary>
167+
private sealed class RootNavigationItemStub : IRootNavigationItem<INavigationModel, INavigationItem>
168+
{
169+
/// <summary>
170+
/// Leaf implementation used by the root stub. Navigation requires both root and leaf nodes present.
171+
/// </summary>
172+
private sealed class LeafNavigationItemStub(RootNavigationItemStub root) : ILeafNavigationItem<INavigationModel>
173+
{
174+
public string Url => "/";
175+
public string NavigationTitle => "Root";
176+
public IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot { get; } = root;
177+
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }
178+
public bool Hidden => false;
179+
public int NavigationIndex { get; set; }
180+
public INavigationModel Model { get; } = new NavigationModelStub();
181+
}
182+
183+
public RootNavigationItemStub() => Index = new LeafNavigationItemStub(this);
184+
185+
public string Url => "/";
186+
public string NavigationTitle => "Root";
187+
public IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot => this;
188+
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }
189+
public bool Hidden => false;
190+
public int NavigationIndex { get; set; }
191+
public string Id => "root";
192+
public ILeafNavigationItem<INavigationModel> Index { get; }
193+
public IReadOnlyCollection<INavigationItem> NavigationItems { get; private set; } = [];
194+
public bool IsUsingNavigationDropdown => false;
195+
public Uri Identifier => new("https://example.test/");
196+
public void SetNavigationItems(IReadOnlyCollection<INavigationItem> navigationItems) => NavigationItems = navigationItems;
197+
}
198+
199+
private static readonly RootNavigationItemStub Root = new();
200+
201+
public string Url { get; } = url;
202+
public string NavigationTitle => "Stub";
203+
public IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot => Root;
204+
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }
205+
public bool Hidden => false;
206+
public int NavigationIndex { get; set; }
207+
}
208+
}

0 commit comments

Comments
 (0)