diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs index 8d2456a9a..7d579cdaf 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs @@ -10,6 +10,7 @@ namespace Elastic.Documentation.Links.CrossLinks; public interface ICrossLinkResolver { bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri); + bool TryGetLinkMetadata(Uri crossLinkUri, [NotNullWhen(true)] out LinkMetadata? linkMetadata); IUriEnvironmentResolver UriResolver { get; } } @@ -24,6 +25,13 @@ public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWh return false; } + /// + public bool TryGetLinkMetadata(Uri crossLinkUri, [NotNullWhen(true)] out LinkMetadata? linkMetadata) + { + linkMetadata = null; + return false; + } + /// public IUriEnvironmentResolver UriResolver { get; } = new IsolatedBuildEnvironmentUriResolver(); @@ -39,6 +47,20 @@ public class CrossLinkResolver(FetchedCrossLinks crossLinks, IUriEnvironmentReso public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => TryResolve(errorEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri); + public bool TryGetLinkMetadata(Uri crossLinkUri, [NotNullWhen(true)] out LinkMetadata? linkMetadata) + { + linkMetadata = null; + + if (!_crossLinks.LinkReferences.TryGetValue(crossLinkUri.Scheme, out var sourceLinkReference)) + return false; + + var originalLookupPath = (crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/'); + if (string.IsNullOrEmpty(originalLookupPath) && crossLinkUri.Host.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + originalLookupPath = crossLinkUri.Host; + + return sourceLinkReference.Links.TryGetValue(originalLookupPath, out linkMetadata); + } + public FetchedCrossLinks UpdateLinkReference(string repository, RepositoryLinks repositoryLinks) { var dictionary = _crossLinks.LinkReferences.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); diff --git a/src/Elastic.Documentation/Links/RepositoryLinks.cs b/src/Elastic.Documentation/Links/RepositoryLinks.cs index b612424d9..ed8f68b2d 100644 --- a/src/Elastic.Documentation/Links/RepositoryLinks.cs +++ b/src/Elastic.Documentation/Links/RepositoryLinks.cs @@ -17,6 +17,10 @@ public record LinkMetadata [JsonPropertyName("hidden")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool Hidden { get; init; } + + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } } public record LinkSingleRedirect diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index 0f2f1b614..9a7310b6e 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -184,6 +184,14 @@ private static void ProcessCrossLink(LinkInline link, InlineProcessor processor, uri, out var resolvedUri) ) link.Url = resolvedUri.ToString(); + + // Handle empty link text by trying to get title from crosslink metadata + if (link.FirstChild == null && context.CrossLinkResolver.TryGetLinkMetadata(uri, out var linkMetadata)) + { + var title = linkMetadata.Title; + if (!string.IsNullOrEmpty(title)) + _ = link.AppendChild(new LiteralInline(title)); + } } private static void ProcessInternalLink(LinkInline link, InlineProcessor processor, ParserContext context) diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs index a7644bf99..98810a3c4 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs @@ -164,6 +164,56 @@ public void EmitsCrossLink() } } +public class CrossLinkEmptyTextTest(ITestOutputHelper output) : LinkTestBase(output, + """ + + Go to [](kibana://index.md) + """ +) +{ + [Fact] + public void GeneratesHtml() => + // language=html + Html.Should().Contain( + """

Go to Kibana Guide

""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + + [Fact] + public void EmitsCrossLink() + { + Collector.CrossLinks.Should().HaveCount(1); + Collector.CrossLinks.Should().Contain("kibana://index.md"); + } +} + +public class CrossLinkEmptyTextNoTitleTest(ITestOutputHelper output) : LinkTestBase(output, + """ + + Go to [](kibana://get-started/index.md) + """ +) +{ + [Fact] + public void GeneratesHtml() => + // language=html - when no title is available, link text should remain empty + Html.Should().Contain( + """

Go to

""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); + + [Fact] + public void EmitsCrossLink() + { + Collector.CrossLinks.Should().HaveCount(1); + Collector.CrossLinks.Should().Contain("kibana://get-started/index.md"); + } +} + public class LinkWithUnresolvedInterpolationError(ITestOutputHelper output) : LinkTestBase(output, """ [global search field]({{this-variable-does-not-exist}}/introduction.html#kibana-navigation-search) diff --git a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs index a1cc334df..5f057d452 100644 --- a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs +++ b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs @@ -30,7 +30,9 @@ public TestCrossLinkResolver() "url_path_prefix": "/elastic/docs-content/tree/main", "cross_links": [], "links": { - "index.md": {}, + "index.md": { + "title": "Kibana Guide" + }, "get-started/index.md": { "anchors": [ "elasticsearch-intro-elastic-stack", @@ -68,4 +70,10 @@ public TestCrossLinkResolver() public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => CrossLinkResolver.TryResolve(errorEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri); + + public bool TryGetLinkMetadata(Uri crossLinkUri, [NotNullWhen(true)] out LinkMetadata? linkMetadata) + { + var resolver = new CrossLinkResolver(_crossLinks, UriResolver); + return resolver.TryGetLinkMetadata(crossLinkUri, out linkMetadata); + } } diff --git a/tests/authoring/Framework/TestCrossLinkResolver.fs b/tests/authoring/Framework/TestCrossLinkResolver.fs index 6657de9d1..774bb3f47 100644 --- a/tests/authoring/Framework/TestCrossLinkResolver.fs +++ b/tests/authoring/Framework/TestCrossLinkResolver.fs @@ -89,4 +89,8 @@ type TestCrossLinkResolver (config: ConfigurationFile) = member this.TryResolve(errorEmitter, crossLinkUri, []resolvedUri : byref) = CrossLinkResolver.TryResolve(errorEmitter, crossLinks, uriResolver, crossLinkUri, &resolvedUri) + member this.TryGetLinkMetadata(crossLinkUri, []linkMetadata : byref) = + let resolver = new CrossLinkResolver(crossLinks, uriResolver) + resolver.TryGetLinkMetadata(crossLinkUri, &linkMetadata) +