Skip to content

Refactor ImageBlock URL handling and file resolution logic. #1219

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 2 additions & 17 deletions src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using System.Diagnostics.CodeAnalysis;
using Elastic.Markdown.Diagnostics;
using Elastic.Markdown.Myst.InlineParsers;
using Elastic.Markdown.Myst.InlineParsers.Substitution;
using Elastic.Markdown.Myst.Settings;
using Elastic.Markdown.Slices.Directives;
Expand Down Expand Up @@ -85,23 +86,7 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo
private static void WriteImage(HtmlRenderer renderer, ImageBlock block)
{
var imageUrl = block.ImageUrl;
if (!string.IsNullOrEmpty(block.ImageUrl))
{
if (block.ImageUrl.StartsWith('/') || block.ImageUrl.StartsWith("_static"))
imageUrl = $"{block.Build.UrlPathPrefix}/{block.ImageUrl.TrimStart('/')}";
else
{
// `block.Build.ConfigurationPath.DirectoryName` is the directory of the docset.yml file
// which is the root of the documentation source
// e.g. `/User/username/Projects/docs-builder/docs`
// `block.CurrentFile.DirectoryName` is the directory of the current markdown file where the image is referenced
// e.g. `/User/username/Projects/docs-builder/docs/page/with/image`
// `Path.GetRelativePath` will return the relative path to the docset.yml directory
// e.g. `page/with/image`
// Hence the full imageUrl will be something like /path-prefix/page/with/image/image.png
imageUrl = block.Build.UrlPathPrefix + '/' + Path.GetRelativePath(block.Build.ConfigurationPath.DirectoryName!, block.CurrentFile.DirectoryName!) + "/" + block.ImageUrl;
}
}

var slice = Image.Create(new ImageViewModel
{
Label = block.Label,
Expand Down
12 changes: 5 additions & 7 deletions src/Elastic.Markdown/Myst/Directives/ImageBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using Elastic.Markdown.Diagnostics;
using Elastic.Markdown.IO;
using Elastic.Markdown.Myst.InlineParsers;

namespace Elastic.Markdown.Myst.Directives;

Expand Down Expand Up @@ -96,17 +97,14 @@ private void ExtractImageUrl(ParserContext context)
return;
}

var includeFrom = context.MarkdownSourcePath.Directory!.FullName;
if (imageUrl.StartsWith('/'))
includeFrom = context.Build.DocumentationSourceDirectory.FullName;
ImageUrl = DiagnosticLinkInlineParser.UpdateRelativeUrl(context, imageUrl);

ImageUrl = imageUrl;
var imagePath = Path.Combine(includeFrom, imageUrl.TrimStart('/'));
var file = context.Build.ReadFileSystem.FileInfo.New(imagePath);

var file = DiagnosticLinkInlineParser.ResolveFile(context, imageUrl);
if (file.Exists)
Found = true;
else
this.EmitError($"`{imageUrl}` does not exist. resolved to `{imagePath}");
this.EmitError($"`{imageUrl}` does not exist. resolved to `{file}");

if (context.DocumentationFileLookup(context.MarkdownSourcePath) is MarkdownFile currentMarkdown)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ private static void ProcessLinkText(InlineProcessor processor, LinkInline link,
_ = link.AppendChild(new LiteralInline(title));
}

private static IFileInfo ResolveFile(ParserContext context, string url) =>
public static IFileInfo ResolveFile(ParserContext context, string url) =>
string.IsNullOrWhiteSpace(url)
? context.MarkdownSourcePath
: url.StartsWith('/')
Expand All @@ -302,49 +302,8 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s
newUrl = linkMarkdown.Url;
}
else
{
// TODO revisit when we refactor our documentation set graph
// This method grew too complex, we need to revisit our documentation set graph generation so we can ask these questions
// on `DocumentationFile` that are mostly precomputed
var urlPathPrefix = context.Build.UrlPathPrefix ?? string.Empty;

if (!newUrl.StartsWith('/') && !string.IsNullOrEmpty(newUrl))
{
// eat overall path prefix since its gets appended later
var subPrefix = context.CurrentUrlPath.Length >= urlPathPrefix.Length
? context.CurrentUrlPath[urlPathPrefix.Length..]
: urlPathPrefix;

// if we are trying to resolve a relative url from a _snippet folder ensure we eat the _snippet folder
// as it's not part of url by chopping of the extra parent navigation
if (newUrl.StartsWith("../") && context.DocumentationFileLookup(context.MarkdownSourcePath) is SnippetFile snippetFile)
newUrl = url.Substring(3);

// TODO check through context.DocumentationFileLookup if file is index vs "index.md" check
var markdownPath = context.MarkdownSourcePath;
// if the current path is an index e.g /reference/cloud-k8s/
// './' current path lookups should be relative to sub-path.
// If it's not e.g /reference/cloud-k8s/api-docs/ these links should resolve on folder up.
var lastIndexPath = subPrefix.LastIndexOf('/');
if (lastIndexPath >= 0 && markdownPath.Name != "index.md")
subPrefix = subPrefix[..lastIndexPath];
var combined = '/' + Path.Combine(subPrefix, newUrl).TrimStart('/');
newUrl = Path.GetFullPath(combined);
}

// 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('\\', '/');
if (newUrl.Length > 2 && newUrl[1] == ':')
newUrl = newUrl[2..];
}
newUrl = UpdateRelativeUrl(context, url);

if (!string.IsNullOrWhiteSpace(newUrl) && !string.IsNullOrWhiteSpace(urlPathPrefix))
newUrl = $"{urlPathPrefix.TrimEnd('/')}{newUrl}";

}

if (newUrl.EndsWith(".md"))
{
Expand All @@ -364,6 +323,52 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s
: newUrl;
}

// TODO revisit when we refactor our documentation set graph
// This method grew too complex, we need to revisit our documentation set graph generation so we can ask these questions
// on `DocumentationFile` that are mostly precomputed
public static string UpdateRelativeUrl(ParserContext context, string url)
{
var urlPathPrefix = context.Build.UrlPathPrefix ?? string.Empty;
var newUrl = url;
if (!newUrl.StartsWith('/') && !string.IsNullOrEmpty(newUrl))
{
var subPrefix = context.CurrentUrlPath.Length >= urlPathPrefix.Length
? context.CurrentUrlPath[urlPathPrefix.Length..]
: urlPathPrefix;

// if we are trying to resolve a relative url from a _snippet folder ensure we eat the _snippet folder
// as it's not part of url by chopping of the extra parent navigation
if (newUrl.StartsWith("../") && context.DocumentationFileLookup(context.MarkdownSourcePath) is SnippetFile)
newUrl = url[3..];

// TODO check through context.DocumentationFileLookup if file is index vs "index.md" check
var markdownPath = context.MarkdownSourcePath;
// if the current path is an index e.g /reference/cloud-k8s/
// './' current path lookups should be relative to sub-path.
// If it's not e.g /reference/cloud-k8s/api-docs/ these links should resolve on folder up.
var lastIndexPath = subPrefix.LastIndexOf('/');
if (lastIndexPath >= 0 && markdownPath.Name != "index.md")
subPrefix = subPrefix[..lastIndexPath];
var combined = '/' + Path.Combine(subPrefix, newUrl).TrimStart('/');
newUrl = Path.GetFullPath(combined);

}
// 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('\\', '/');
if (newUrl.Length > 2 && newUrl[1] == ':')
newUrl = newUrl[2..];
}

if (!string.IsNullOrWhiteSpace(newUrl) && !string.IsNullOrWhiteSpace(urlPathPrefix))
newUrl = $"{urlPathPrefix.TrimEnd('/')}{newUrl}";

// eat overall path prefix since its gets appended later
return newUrl;
}

private static bool IsCrossLink([NotNullWhen(true)] Uri? uri) =>
uri != null // This means it's not a local
&& !ExcludedSchemes.Contains(uri.Scheme)
Expand Down
2 changes: 1 addition & 1 deletion tests/Elastic.Markdown.Tests/Directives/ImageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public void ParsesBreakPoint()
{
Block!.Alt.Should().Be("Elasticsearch");
Block!.Width.Should().Be("250px");
Block!.ImageUrl.Should().Be("img/observability.png");
Block!.ImageUrl.Should().Be("/img/observability.png");
Block!.Screenshot.Should().Be("screenshot");
}

Expand Down
81 changes: 81 additions & 0 deletions tests/authoring/Blocks/ImageBlocks.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 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
module ``block elements``.``image blocks``

open Xunit
open authoring

type ``static path to image`` () =
static let markdown = Setup.Markdown """
:::{image} img/observability.png
:alt: Elasticsearch
:width: 250px
:screenshot:
:::
"""

[<Fact>]
let ``validate src is anchored`` () =
markdown |> convertsToContainingHtml """
<img loading="lazy" alt="Elasticsearch" src="/img/observability.png" style="width: 250px;" class="screenshot">
"""

type ``supports --url-path-prefix`` () =
static let docs = Setup.GenerateWithOptions { UrlPathPrefix = Some "/docs" } [
Static "img/observability.png"

Index """# Testing nested inline anchors
:::{image} img/observability.png
:alt: Elasticsearch
:width: 250px
:screenshot:
:::
"""

Markdown "folder/relative.md" """
:::{image} ../img/observability.png
:alt: Elasticsearch
:width: 250px
:screenshot:
:::
"""
]

[<Fact>]
let ``validate image src contains prefix`` () =
docs |> convertsToContainingHtml """
<img loading="lazy" alt="Elasticsearch" src="/docs/img/observability.png" style="width: 250px;" class="screenshot">
"""

[<Fact>]
let ``validate image src contains prefix when referenced relatively`` () =
docs |> converts "folder/relative.md" |> containsHtml """
<img loading="lazy" alt="Elasticsearch" src="/docs/img/observability.png" style="width: 250px;" class="screenshot">
"""

[<Fact>]
let ``has no errors`` () = docs |> hasNoErrors

type ``image ref out of scope`` () =
static let docs = Setup.GenerateWithOptions { UrlPathPrefix = Some "/docs" } [
Static "img/observability.png"

Index """# Testing nested inline anchors
:::{image} ../img/observability.png
:alt: Elasticsearch
:width: 250px
:screenshot:
:::
"""
]

[<Fact>]
let ``validate image src contains prefix and is anchored to documentation scope root`` () =
docs |> convertsToContainingHtml """
<img loading="lazy" alt="Elasticsearch" src="/docs/img/observability.png" style="width: 250px;" class="screenshot">
"""

[<Fact>]
let ``emits an error image reference is outside of documentation scope`` () =
docs |> hasError "./img/observability.png` does not exist. resolved to"
1 change: 1 addition & 0 deletions tests/authoring/Container/DefinitionLists.fs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ This is my `definition`
</dd>
</dl>
"""

[<Fact>]
let ``has no errors 2`` () = markdown |> hasNoErrors

Expand Down
28 changes: 21 additions & 7 deletions tests/authoring/Framework/Setup.fs
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,25 @@ type TestFile =
| File of name: string * contents: string
| MarkdownFile of name: string * markdown: Markdown
| SnippetFile of name: string * markdown: Markdown
| StaticFile of name: string

static member Index ([<LanguageInjection("markdown")>] m) =
MarkdownFile("index.md" , m)

static member Markdown path ([<LanguageInjection("markdown")>] m) =
MarkdownFile(path , m)

static member Static path = StaticFile(path)

static member Snippet path ([<LanguageInjection("markdown")>] m) =
SnippetFile(path , m)

type SetupOptions =
{ UrlPathPrefix: string option }
static member Empty = {
UrlPathPrefix = None
}

type Setup =

static let GenerateDocSetYaml(
Expand All @@ -61,7 +70,7 @@ type Setup =
yaml.WriteLine($" - file: {relative}")
let fullPath = Path.Combine(root.FullName, relative)
let contents = File.ReadAllText fullPath
fileSystem.AddFile(fullPath, new MockFileData(contents))
fileSystem.AddFile(fullPath, MockFileData(contents))
)

match globalVariables with
Expand All @@ -79,14 +88,16 @@ type Setup =
let redirectYaml = File.ReadAllText(Path.Combine(root.FullName, "_redirects.yml"))
fileSystem.AddFile(Path.Combine(root.FullName, redirectsName), MockFileData(redirectYaml))

static member Generator (files: TestFile seq) : Task<GeneratorResults> =
static member Generator (files: TestFile seq) (options: SetupOptions option) : Task<GeneratorResults> =
let options = options |> Option.defaultValue SetupOptions.Empty

let d = files
|> Seq.map (fun f ->
match f with
| File(name, contents) -> ($"docs/{name}", MockFileData(contents))
| SnippetFile(name, markdown) -> ($"docs/{name}", MockFileData(markdown))
| MarkdownFile(name, markdown) -> ($"docs/{name}", MockFileData(markdown))
| StaticFile(name) -> ($"docs/{name}", MockFileData(""))
)
|> Map.ofSeq

Expand All @@ -95,8 +106,8 @@ type Setup =

GenerateDocSetYaml (fileSystem, None)

let collector = TestDiagnosticsCollector();
let context = BuildContext(collector, fileSystem)
let collector = TestDiagnosticsCollector()
let context = BuildContext(collector, fileSystem, UrlPathPrefix=(options.UrlPathPrefix |> Option.defaultValue ""))
let logger = new TestLoggerFactory()
let conversionCollector = TestConversionCollector()
let linkResolver = TestCrossLinkResolver(context.Configuration)
Expand All @@ -115,11 +126,14 @@ type Setup =

/// Pass several files to the test setup
static member Generate files =
lazy (task { return! Setup.Generator files } |> Async.AwaitTask |> Async.RunSynchronously)
lazy (task { return! Setup.Generator files None } |> Async.AwaitTask |> Async.RunSynchronously)

static member GenerateWithOptions options files =
lazy (task { return! Setup.Generator files (Some options) } |> Async.AwaitTask |> Async.RunSynchronously)

/// Pass a full documentation page to the test setup
static member Document ([<LanguageInjection("markdown")>]m: string) =
lazy (task { return! Setup.Generator [Index m] } |> Async.AwaitTask |> Async.RunSynchronously)
lazy (task { return! Setup.Generator [Index m] None } |> Async.AwaitTask |> Async.RunSynchronously)

/// Pass a markdown fragment to the test setup
static member Markdown ([<LanguageInjection("markdown")>]m: string) =
Expand All @@ -128,6 +142,6 @@ type Setup =
{m}
"""
lazy (
task { return! Setup.Generator [Index m] }
task { return! Setup.Generator [Index m] None }
|> Async.AwaitTask |> Async.RunSynchronously
)
1 change: 1 addition & 0 deletions tests/authoring/authoring.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
<Compile Include="Blocks\CodeBlocks\CodeBlocks.fs"/>
<Compile Include="Blocks\Lists.fs"/>
<Compile Include="Blocks\Admonitions.fs"/>
<Compile Include="Blocks\ImageBlocks.fs" />
<Compile Include="Applicability\AppliesToFrontMatter.fs" />
<Compile Include="Applicability\AppliesToDirective.fs" />
<Compile Include="Directives\IncludeBlocks.fs" />
Expand Down
Loading