Skip to content

Use substitution values in image alt and title text #1163

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 11 commits into from
May 20, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ private static void WriteImage(HtmlRenderer renderer, ImageBlock block)
{
Label = block.Label,
Align = block.Align,
Alt = block.Alt,
Alt = block.Alt ?? string.Empty,
Title = block.Title,
Height = block.Height,
Scale = block.Scale,
Target = block.Target,
Expand Down Expand Up @@ -128,7 +129,8 @@ private static void WriteFigure(HtmlRenderer renderer, ImageBlock block)
{
Label = block.Label,
Align = block.Align,
Alt = block.Alt,
Alt = block.Alt ?? string.Empty,
Title = block.Title,
Height = block.Height,
Scale = block.Scale,
Target = block.Target,
Expand Down
13 changes: 9 additions & 4 deletions src/Elastic.Markdown/Myst/Directives/ImageBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information

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

Expand All @@ -21,6 +22,11 @@ public class ImageBlock(DirectiveBlockParser parser, ParserContext context)
/// </summary>
public string? Alt { get; set; }

/// <summary>
/// Title text: a short description of the image
/// </summary>
public string? Title { get; set; }

/// <summary>
/// The desired height of the image. Used to reserve space or scale the image vertically. When the “scale” option
/// is also specified, they are combined. For example, a height of 200px and a scale of 50 is equivalent to
Expand Down Expand Up @@ -65,9 +71,10 @@ public class ImageBlock(DirectiveBlockParser parser, ParserContext context)
public override void FinalizeAndValidate(ParserContext context)
{
Label = Prop("label", "name");
Alt = Prop("alt");
Align = Prop("align");
Alt = Prop("alt")?.ReplaceSubstitutions(context) ?? string.Empty;
Title = Prop("title")?.ReplaceSubstitutions(context);

Align = Prop("align");
Height = Prop("height", "h");
Width = Prop("width", "w");

Expand Down Expand Up @@ -112,5 +119,3 @@ private void ExtractImageUrl(ParserContext context)
}
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -63,38 +63,41 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice)

ValidateAndProcessLink(link, processor, context);

ParseStylingInstructions(link);
ParseStylingInstructions(link, context);

return match;
}


private static void ParseStylingInstructions(LinkInline link)
private static void ParseStylingInstructions(LinkInline link, ParserContext context)
{
if (!link.IsImage)
return;

if (string.IsNullOrWhiteSpace(link.Title) || link.Title.IndexOf('=') < 0)
return;
var attributes = link.GetAttributes();
var title = link.Title;

var matches = LinkRegexExtensions.MatchTitleStylingInstructions().Match(link.Title);
if (!matches.Success)
if (string.IsNullOrEmpty(title))
return;

var width = matches.Groups["width"].Value;
if (!width.EndsWith('%'))
width += "px";
var height = matches.Groups["height"].Value;
if (string.IsNullOrEmpty(height))
height = width;
else if (!height.EndsWith('%'))
height += "px";
var title = link.Title[..matches.Index];

link.Title = title;
var attributes = link.GetAttributes();
attributes.AddProperty("width", width);
attributes.AddProperty("height", height);
var matches = LinkRegexExtensions.MatchTitleStylingInstructions().Match(title);
if (matches.Success)
{
var width = matches.Groups["width"].Value;
if (!width.EndsWith('%'))
width += "px";
var height = matches.Groups["height"].Value;
if (string.IsNullOrEmpty(height))
height = width;
else if (!height.EndsWith('%'))
height += "px";

attributes.AddProperty("width", width);
attributes.AddProperty("height", height);

title = title[..matches.Index];
}
link.Title = title?.ReplaceSubstitutions(context);
}

private static bool IsInCommentBlock(LinkInline link) =>
Expand Down Expand Up @@ -183,7 +186,7 @@ private static void ProcessCrossLink(LinkInline link, InlineProcessor processor,
s => processor.EmitError(link, s),
s => processor.EmitWarning(link, s),
uri, out var resolvedUri)
)
)
link.Url = resolvedUri.ToString();
}

Expand Down
5 changes: 3 additions & 2 deletions src/Elastic.Markdown/Slices/Directives/Image.cshtml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@using Microsoft.AspNetCore.Mvc.ModelBinding.Validation
@inherits RazorSlice<ImageViewModel>
<a class="reference internal image-reference" href="javascript:void(0)" onclick="document.getElementById('modal-@Model.UniqueImageId').style.display='flex'">
<img loading="lazy" alt="@Model.Alt" src="@Model.ImageUrl" style="@Model.Style" class="@Model.Screenshot" />
<img loading="lazy" title="@Model.Title" alt="@(Model.Alt == string.Empty ? HtmlString.Empty : new HtmlString(Model.Alt))" src="@Model.ImageUrl" style="@Model.Style" class="@Model.Screenshot" />
[CONTENT]
</a>

Expand All @@ -14,7 +15,7 @@
</a>
</span>
<a class="reference internal image-reference" href="@Model.ImageUrl" target="_blank">
<img loading="lazy" alt="@Model.Alt" src="@Model.ImageUrl" />
<img loading="lazy" title="@Model.Title" alt="@(Model.Alt == string.Empty ? HtmlString.Empty : new HtmlString(Model.Alt))" src="@Model.ImageUrl" />
</a>
</div>
</div>
3 changes: 2 additions & 1 deletion src/Elastic.Markdown/Slices/Directives/_ViewModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ public class ImageViewModel
{
public required string? Label { get; init; }
public required string? Align { get; init; }
public required string? Alt { get; init; }
public required string Alt { get; init; }
public required string? Title { get; init; }
public required string? Height { get; init; }
public required string? Scale { get; init; }
public required string? Target { get; init; }
Expand Down
40 changes: 40 additions & 0 deletions tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,43 @@ public void OnlySeesGlobalVariable() =>
public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);

}

public class ReplaceInImageAlt(ITestOutputHelper output) : InlineTest(output,
"""
---
sub:
hello-world: Hello World
---

# Testing ReplaceInImageAlt

![{{hello-world}}](_static/img/observability.png)
"""
)
{

[Fact]
public void OnlySeesGlobalVariable() =>
Html.Should().NotContain("alt=\"{{hello-world}}\"")
.And.Contain("alt=\"Hello World\"");
}

public class ReplaceInImageTitle(ITestOutputHelper output) : InlineTest(output,
"""
---
sub:
hello-world: Hello World
---

# Testing ReplaceInImageTitle

![Observability](_static/img/observability.png "{{hello-world}}")
"""
)
{

[Fact]
public void OnlySeesGlobalVariable() =>
Html.Should().NotContain("title=\"{{hello-world}}\"")
.And.Contain("title=\"Hello World\"");
}
14 changes: 14 additions & 0 deletions tests/authoring/Blocks/ImageBlocks.fs
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,17 @@ type ``image ref out of scope`` () =
[<Fact>]
let ``emits an error image reference is outside of documentation scope`` () =
docs |> hasError "./img/observability.png` does not exist. resolved to"

type ``empty alt attribute`` () =
static let markdown = Setup.Markdown """
:::{image} img/some-image.png
:alt:
:width: 250px
:::
"""

[<Fact>]
let ``validate empty alt attribute`` () =
markdown |> convertsToContainingHtml """
<img loading="lazy" alt src="/img/some-image.png" style="width: 250px;">
"""
Loading