|
| 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.Collections.Frozen; |
| 6 | +using System.Diagnostics.CodeAnalysis; |
| 7 | +using System.Text.Json; |
| 8 | +using Elastic.Markdown.IO.Configuration; |
| 9 | +using Elastic.Markdown.IO.State; |
| 10 | +using Microsoft.Extensions.Logging; |
| 11 | + |
| 12 | +namespace Elastic.Markdown.CrossLinks; |
| 13 | + |
| 14 | +public interface ICrossLinkResolver |
| 15 | +{ |
| 16 | + Task FetchLinks(); |
| 17 | + bool TryResolve(Action<string> errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri); |
| 18 | +} |
| 19 | + |
| 20 | +public class CrossLinkResolver(ConfigurationFile configuration, ILoggerFactory logger) : ICrossLinkResolver |
| 21 | +{ |
| 22 | + private readonly string[] _links = configuration.CrossLinkRepositories; |
| 23 | + private FrozenDictionary<string, LinkReference> _linkReferences = new Dictionary<string, LinkReference>().ToFrozenDictionary(); |
| 24 | + private readonly ILogger _logger = logger.CreateLogger(nameof(CrossLinkResolver)); |
| 25 | + |
| 26 | + public static LinkReference Deserialize(string json) => |
| 27 | + JsonSerializer.Deserialize(json, SourceGenerationContext.Default.LinkReference)!; |
| 28 | + |
| 29 | + public async Task FetchLinks() |
| 30 | + { |
| 31 | + using var client = new HttpClient(); |
| 32 | + var dictionary = new Dictionary<string, LinkReference>(); |
| 33 | + foreach (var link in _links) |
| 34 | + { |
| 35 | + var url = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{link}/main/links.json"; |
| 36 | + _logger.LogInformation($"Fetching {url}"); |
| 37 | + var json = await client.GetStringAsync(url); |
| 38 | + var linkReference = Deserialize(json); |
| 39 | + dictionary.Add(link, linkReference); |
| 40 | + } |
| 41 | + _linkReferences = dictionary.ToFrozenDictionary(); |
| 42 | + } |
| 43 | + |
| 44 | + public bool TryResolve(Action<string> errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => |
| 45 | + TryResolve(errorEmitter, _linkReferences, crossLinkUri, out resolvedUri); |
| 46 | + |
| 47 | + private static Uri BaseUri { get; } = new Uri("https://docs-v3-preview.elastic.dev"); |
| 48 | + |
| 49 | + public static bool TryResolve(Action<string> errorEmitter, IDictionary<string, LinkReference> lookup, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) |
| 50 | + { |
| 51 | + resolvedUri = null; |
| 52 | + if (!lookup.TryGetValue(crossLinkUri.Scheme, out var linkReference)) |
| 53 | + { |
| 54 | + errorEmitter($"'{crossLinkUri.Scheme}' is not declared as valid cross link repository in docset.yml under cross_links"); |
| 55 | + return false; |
| 56 | + } |
| 57 | + var lookupPath = crossLinkUri.AbsolutePath.TrimStart('/'); |
| 58 | + if (string.IsNullOrEmpty(lookupPath) && crossLinkUri.Host.EndsWith(".md")) |
| 59 | + lookupPath = crossLinkUri.Host; |
| 60 | + |
| 61 | + if (!linkReference.Links.TryGetValue(lookupPath, out var link)) |
| 62 | + { |
| 63 | + errorEmitter($"'{lookupPath}' is not a valid link in the '{crossLinkUri.Scheme}' cross link repository."); |
| 64 | + return false; |
| 65 | + } |
| 66 | + |
| 67 | + //https://docs-v3-preview.elastic.dev/elastic/docs-content/tree/main/cloud-account/change-your-password |
| 68 | + var path = lookupPath.Replace(".md", ""); |
| 69 | + if (path.EndsWith("/index")) |
| 70 | + path = path.Substring(0, path.Length - 6); |
| 71 | + if (path == "index") |
| 72 | + path = string.Empty; |
| 73 | + |
| 74 | + if (!string.IsNullOrEmpty(crossLinkUri.Fragment)) |
| 75 | + { |
| 76 | + if (link.Anchors is null) |
| 77 | + { |
| 78 | + errorEmitter($"'{lookupPath}' does not have any anchors so linking to '{crossLinkUri.Fragment}' is impossible."); |
| 79 | + return false; |
| 80 | + } |
| 81 | + |
| 82 | + if (!link.Anchors.Contains(crossLinkUri.Fragment.TrimStart('#'))) |
| 83 | + { |
| 84 | + errorEmitter($"'{lookupPath}' has no anchor named: '{crossLinkUri.Fragment}'."); |
| 85 | + return false; |
| 86 | + } |
| 87 | + path += crossLinkUri.Fragment; |
| 88 | + } |
| 89 | + |
| 90 | + resolvedUri = new Uri(BaseUri, $"elastic/{crossLinkUri.Scheme}/tree/main/{path}"); |
| 91 | + return true; |
| 92 | + } |
| 93 | +} |
0 commit comments