diff --git a/src/Elastic.Markdown/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Markdown/CrossLinks/CrossLinkResolver.cs index fd0c37b60..9808ce11e 100644 --- a/src/Elastic.Markdown/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Markdown/CrossLinks/CrossLinkResolver.cs @@ -22,6 +22,7 @@ public class CrossLinkResolver(ConfigurationFile configuration, ILoggerFactory l private readonly string[] _links = configuration.CrossLinkRepositories; private FrozenDictionary _linkReferences = new Dictionary().ToFrozenDictionary(); private readonly ILogger _logger = logger.CreateLogger(nameof(CrossLinkResolver)); + private readonly HashSet _declaredRepositories = new(); public static LinkReference Deserialize(string json) => JsonSerializer.Deserialize(json, SourceGenerationContext.Default.LinkReference)!; @@ -32,28 +33,65 @@ public async Task FetchLinks() var dictionary = new Dictionary(); foreach (var link in _links) { - var url = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{link}/main/links.json"; - _logger.LogInformation($"Fetching {url}"); - var json = await client.GetStringAsync(url); - var linkReference = Deserialize(json); - dictionary.Add(link, linkReference); + _declaredRepositories.Add(link); + try + { + var url = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{link}/main/links.json"; + _logger.LogInformation($"Fetching {url}"); + var json = await client.GetStringAsync(url); + var linkReference = Deserialize(json); + dictionary.Add(link, linkReference); + } + catch when (link == "docs-content") + { + throw; + } + catch when (link != "docs-content") + { + // TODO: ignored for now while we wait for all links.json files to populate + } } _linkReferences = dictionary.ToFrozenDictionary(); } public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => - TryResolve(errorEmitter, _linkReferences, crossLinkUri, out resolvedUri); + TryResolve(errorEmitter, _declaredRepositories, _linkReferences, crossLinkUri, out resolvedUri); private static Uri BaseUri { get; } = new Uri("https://docs-v3-preview.elastic.dev"); - public static bool TryResolve(Action errorEmitter, IDictionary lookup, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) + public static bool TryResolve(Action errorEmitter, HashSet declaredRepositories, IDictionary lookup, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) { resolvedUri = null; - if (!lookup.TryGetValue(crossLinkUri.Scheme, out var linkReference)) + if (crossLinkUri.Scheme == "docs-content") + { + if (!lookup.TryGetValue(crossLinkUri.Scheme, out var linkReference)) + { + errorEmitter($"'{crossLinkUri.Scheme}' is not declared as valid cross link repository in docset.yml under cross_links"); + return false; + } + return TryFullyValidate(errorEmitter, linkReference, crossLinkUri, out resolvedUri); + } + + // TODO this is temporary while we wait for all links.json files to be published + if (!declaredRepositories.Contains(crossLinkUri.Scheme)) { errorEmitter($"'{crossLinkUri.Scheme}' is not declared as valid cross link repository in docset.yml under cross_links"); return false; } + + var lookupPath = crossLinkUri.AbsolutePath.TrimStart('/'); + var path = ToTargetUrlPath(lookupPath); + if (!string.IsNullOrEmpty(crossLinkUri.Fragment)) + path += crossLinkUri.Fragment; + + var branch = GetBranch(crossLinkUri); + resolvedUri = new Uri(BaseUri, $"elastic/{crossLinkUri.Scheme}/tree/{branch}/{path}"); + return true; + } + + private static bool TryFullyValidate(Action errorEmitter, LinkReference linkReference, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) + { + resolvedUri = null; var lookupPath = crossLinkUri.AbsolutePath.TrimStart('/'); if (string.IsNullOrEmpty(lookupPath) && crossLinkUri.Host.EndsWith(".md")) lookupPath = crossLinkUri.Host; @@ -64,12 +102,7 @@ public static bool TryResolve(Action errorEmitter, IDictionary errorEmitter, IDictionary "main", + _ => "main" + }; + return branch; + } + + + private static string ToTargetUrlPath(string lookupPath) + { + //https://docs-v3-preview.elastic.dev/elastic/docs-content/tree/main/cloud-account/change-your-password + var path = lookupPath.Replace(".md", ""); + if (path.EndsWith("/index")) + path = path.Substring(0, path.Length - 6); + if (path == "index") + path = string.Empty; + return path; + } } diff --git a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs index 5e118ea39..988a688da 100644 --- a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs +++ b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs @@ -5,12 +5,14 @@ using System.Diagnostics.CodeAnalysis; using Elastic.Markdown.CrossLinks; using Elastic.Markdown.IO.State; +using Xunit.Internal; namespace Elastic.Markdown.Tests; public class TestCrossLinkResolver : ICrossLinkResolver { public Dictionary LinkReferences { get; } = new(); + public HashSet DeclaredRepositories { get; } = new(); public Task FetchLinks() { @@ -40,9 +42,10 @@ public Task FetchLinks() var reference = CrossLinkResolver.Deserialize(json); LinkReferences.Add("docs-content", reference); LinkReferences.Add("kibana", reference); + DeclaredRepositories.AddRange(["docs-content", "kibana", "elasticsearch"]); return Task.CompletedTask; } public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => - CrossLinkResolver.TryResolve(errorEmitter, LinkReferences, crossLinkUri, out resolvedUri); + CrossLinkResolver.TryResolve(errorEmitter, DeclaredRepositories, LinkReferences, crossLinkUri, out resolvedUri); } diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index c103b052d..28f67b44b 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -42,6 +42,8 @@ type Setup = let yaml = new StringWriter(); yaml.WriteLine("cross_links:"); yaml.WriteLine(" - docs-content"); + yaml.WriteLine(" - elasticsearch"); + yaml.WriteLine(" - kibana"); yaml.WriteLine("toc:"); let markdownFiles = fileSystem.Directory.EnumerateFiles(root.FullName, "*.md", SearchOption.AllDirectories) markdownFiles diff --git a/tests/authoring/Framework/TestCrossLinkResolver.fs b/tests/authoring/Framework/TestCrossLinkResolver.fs index 2d6927bd5..ec5d9e38d 100644 --- a/tests/authoring/Framework/TestCrossLinkResolver.fs +++ b/tests/authoring/Framework/TestCrossLinkResolver.fs @@ -14,7 +14,10 @@ open Elastic.Markdown.IO.State type TestCrossLinkResolver () = let references = Dictionary() + let declared = HashSet() + member this.LinkReferences = references + member this.DeclaredRepositories = declared interface ICrossLinkResolver with member this.FetchLinks() = @@ -44,9 +47,12 @@ type TestCrossLinkResolver () = let reference = CrossLinkResolver.Deserialize json this.LinkReferences.Add("docs-content", reference) this.LinkReferences.Add("kibana", reference) + this.DeclaredRepositories.Add("docs-content") |> ignore; + this.DeclaredRepositories.Add("kibana") |> ignore; + this.DeclaredRepositories.Add("elasticsearch") |> ignore; Task.CompletedTask member this.TryResolve(errorEmitter, crossLinkUri, []resolvedUri : byref) = - CrossLinkResolver.TryResolve(errorEmitter, this.LinkReferences, crossLinkUri, &resolvedUri); + CrossLinkResolver.TryResolve(errorEmitter, this.DeclaredRepositories, this.LinkReferences, crossLinkUri, &resolvedUri); diff --git a/tests/authoring/Inline/CrossLinks.fs b/tests/authoring/Inline/CrossLinks.fs index 8c8d934f8..a21ca7df9 100644 --- a/tests/authoring/Inline/CrossLinks.fs +++ b/tests/authoring/Inline/CrossLinks.fs @@ -78,3 +78,25 @@ type ``link to valid anchor`` () = [] let ``has no warning`` () = markdown |> hasNoWarnings + +type ``link to repository that does not resolve yet`` () = + + static let markdown = Setup.Markdown """ +[Elasticsearch Documentation](elasticsearch:/index.md) +""" + + [] + let ``validate HTML`` () = + markdown |> convertsToHtml """ +

+ Elasticsearch Documentation + +

+ """ + + [] + let ``has no errors`` () = markdown |> hasNoErrors + + [] + let ``has no warning`` () = markdown |> hasNoWarnings