From 62fbf841b616aa122c84b5adbd27682fca65d22f Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 8 May 2025 06:14:19 -0300 Subject: [PATCH 1/4] Support cross-repository redirects and specialized anchor scenarios --- docs/contribute/redirects.md | 57 +++++ src/Elastic.Markdown/IO/DocumentationSet.cs | 8 + .../Links/CrossLinks/CrossLinkResolver.cs | 218 ++++++++++-------- .../Myst/Renderers/HtmxLinkInlineRenderer.cs | 9 +- 4 files changed, 188 insertions(+), 104 deletions(-) diff --git a/docs/contribute/redirects.md b/docs/contribute/redirects.md index 7cd26603e..995a7a826 100644 --- a/docs/contribute/redirects.md +++ b/docs/contribute/redirects.md @@ -49,6 +49,19 @@ redirects: 'testing/redirects/third-page.md': anchors: 'removed-anchor': + 'testing/redirects/cross-repo-page.md': 'other-repo://reference/section/new-cross-repo-page.md' + 'testing/redirects/8th-page.md': + to: 'other-repo://testing/redirects/5th-page.md' + anchors: '!' + many: + - to: 'testing/redirects/second-page.md' + anchors: + 'item-a': 'yy' + - to: 'testing/redirects/third-page.md' + anchors: + 'item-b': + + ``` ### Redirect preserving all anchors @@ -104,3 +117,47 @@ redirects: 'old-anchor': 'active-anchor' 'removed-anchor': ``` + +### Redirecting to other repositories + +It is possible to redirect to other repositories. The syntax is the same as when linking on documentation sets: + +* 'other-repo://reference/section/new-cross-repo-page.md' + +```yaml +redirects: + 'testing/redirects/cross-repo-page.md': 'other-repo://reference/section/new-cross-repo-page.md' +``` + +### Managing complex scenarios with anchors + +* `to`, `anchor` and `many` can be used together to support more complex scenarios. +* Setting `to` at the top level determines the default case, which can be used for partial redirects. +* Cross-repository links are supported, with the same syntax as in the previous example. +* The existing rules for `anchors` also apply here. To define a catch-all redirect, use `{}`. + +```yaml +redirects: + 'testing/redirects/8th-page.md': + to: 'testing/redirects/8th-page.md' + anchors: {} + many: + - to: 'testing/redirects/second-page.md' + anchors: + 'item-a': 'yy' + - to: 'testing/redirects/third-page.md' + anchors: + 'item-b': + + 'testing/redirects/deleted-page.md': + to: 'testing/redirects/5th-page.md' + anchors: '!' + many: + - to: "testing/redirects/second-page.md" + anchors: + "aa": "zz" + "removed-anchor": + - to: "other-repo://reference/section/partial-content.md" + anchors: + "bb": "yy" +``` diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index ca8fe4093..72c6c9414 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -256,6 +256,14 @@ private void ValidateRedirectsExists() void ValidateExists(string from, string to, IReadOnlyDictionary? valueAnchors) { + if (to.Contains("://")) + { + if (!Uri.TryCreate(to, UriKind.Absolute, out _)) + Context.EmitError(Configuration.SourceFile, $"Redirect {from} points to {to} which is not a valid URI"); + + return; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) to = to.Replace('/', Path.DirectorySeparatorChar); diff --git a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs index b623a8237..d39bbec3b 100644 --- a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs @@ -4,7 +4,6 @@ using System.Collections.Frozen; using System.Diagnostics.CodeAnalysis; -using Elastic.Documentation; using Elastic.Documentation.Links; namespace Elastic.Markdown.Links.CrossLinks; @@ -51,155 +50,172 @@ public static bool TryResolve( ) { resolvedUri = null; - var lookup = fetchedCrossLinks.LinkReferences; - if (crossLinkUri.Scheme != "asciidocalypse" && lookup.TryGetValue(crossLinkUri.Scheme, out var linkReference)) - return TryFullyValidate(errorEmitter, uriResolver, fetchedCrossLinks, linkReference, crossLinkUri, out resolvedUri); - // TODO this is temporary while we wait for all links.json to be published - // Here we just silently rewrite the cross_link to the url - - var declaredRepositories = fetchedCrossLinks.DeclaredRepositories; - if (!declaredRepositories.Contains(crossLinkUri.Scheme)) + if (crossLinkUri.Scheme.Equals("asciidocalypse") || !fetchedCrossLinks.LinkReferences.TryGetValue(crossLinkUri.Scheme, out var sourceLinkReference)) { - if (fetchedCrossLinks.FromConfiguration) - errorEmitter($"'{crossLinkUri.Scheme}' is not declared as valid cross link repository in docset.yml under cross_links: '{crossLinkUri}'"); - else - warningEmitter($"'{crossLinkUri.Scheme}' is not yet publishing to the links registry: '{crossLinkUri}'"); - return false; + // TODO this is temporary while we wait for all links.json to be published + // Here we just silently rewrite the cross_link to the url + + var declaredRepositories = fetchedCrossLinks.DeclaredRepositories; + if (!declaredRepositories.Contains(crossLinkUri.Scheme)) + { + if (fetchedCrossLinks.FromConfiguration) + errorEmitter($"'{crossLinkUri.Scheme}' is not declared as valid cross link repository in docset.yml under cross_links: '{crossLinkUri}'"); + else + warningEmitter($"'{crossLinkUri.Scheme}' is not yet publishing to the links registry: '{crossLinkUri}'"); + return false; + } + + var lookupPathFallback = (crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/'); + var pathFallback = ToTargetUrlPath(lookupPathFallback); + if (!string.IsNullOrEmpty(crossLinkUri.Fragment)) + pathFallback += crossLinkUri.Fragment; + + resolvedUri = uriResolver.Resolve(crossLinkUri, pathFallback); + return true; } - var lookupPath = (crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/'); - var path = ToTargetUrlPath(lookupPath); - if (!string.IsNullOrEmpty(crossLinkUri.Fragment)) - path += crossLinkUri.Fragment; + var originalLookupPath = (crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/'); + if (string.IsNullOrEmpty(originalLookupPath) && crossLinkUri.Host.EndsWith(".md")) + originalLookupPath = crossLinkUri.Host; - resolvedUri = uriResolver.Resolve(crossLinkUri, path); - return true; + if (sourceLinkReference.Redirects is not null && sourceLinkReference.Redirects.TryGetValue(originalLookupPath, out var redirectRule)) + return ResolveRedirect(errorEmitter, uriResolver, crossLinkUri, redirectRule, originalLookupPath, fetchedCrossLinks, out resolvedUri); + + if (sourceLinkReference.Links.TryGetValue(originalLookupPath, out var directLinkMetadata)) + return ResolveDirectLink(errorEmitter, uriResolver, crossLinkUri, originalLookupPath, directLinkMetadata, out resolvedUri); + + + var linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{crossLinkUri.Scheme}/main/links.json"; + if (fetchedCrossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var indexEntry)) + linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/{indexEntry.Path}"; + + errorEmitter($"'{originalLookupPath}' is not a valid link in the '{crossLinkUri.Scheme}' cross link index: {linksJson}"); + resolvedUri = null; + return false; } - private static bool TryFullyValidate(Action errorEmitter, + private static bool ResolveDirectLink(Action errorEmitter, IUriEnvironmentResolver uriResolver, - FetchedCrossLinks fetchedCrossLinks, - LinkReference linkReference, Uri crossLinkUri, + string lookupPath, + LinkMetadata linkMetadata, [NotNullWhen(true)] out Uri? resolvedUri) { resolvedUri = null; - var lookupPath = (crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/'); - if (string.IsNullOrEmpty(lookupPath) && crossLinkUri.Host.EndsWith(".md")) - lookupPath = crossLinkUri.Host; - - if (!LookupLink(errorEmitter, fetchedCrossLinks, linkReference, crossLinkUri, ref lookupPath, out var link, out var lookupFragment)) - return false; - - var path = ToTargetUrlPath(lookupPath); + var lookupFragment = crossLinkUri.Fragment; + var targetUrlPath = ToTargetUrlPath(lookupPath); if (!string.IsNullOrEmpty(lookupFragment)) { - if (link.Anchors is null) - { - errorEmitter($"'{lookupPath}' does not have any anchors so linking to '{crossLinkUri.Fragment}' is impossible."); - return false; - } - - if (!link.Anchors.Contains(lookupFragment.TrimStart('#'))) + var anchor = lookupFragment.TrimStart('#'); + if (linkMetadata.Anchors is null || !linkMetadata.Anchors.Contains(anchor)) { errorEmitter($"'{lookupPath}' has no anchor named: '{lookupFragment}'."); return false; } - path += "#" + lookupFragment.TrimStart('#'); + targetUrlPath += lookupFragment; } - resolvedUri = uriResolver.Resolve(crossLinkUri, path); + resolvedUri = uriResolver.Resolve(crossLinkUri, targetUrlPath); return true; } - private static bool LookupLink(Action errorEmitter, - FetchedCrossLinks crossLinks, - LinkReference linkReference, - Uri crossLinkUri, - ref string lookupPath, - [NotNullWhen(true)] out LinkMetadata? link, - [NotNullWhen(true)] out string? lookupFragment) + private static bool ResolveRedirect( + Action errorEmitter, + IUriEnvironmentResolver uriResolver, + Uri originalCrossLinkUri, + LinkRedirect redirectRule, + string originalLookupPath, + FetchedCrossLinks fetchedCrossLinks, + [NotNullWhen(true)] out Uri? resolvedUri) { - lookupFragment = null; + resolvedUri = null; + var originalFragment = originalCrossLinkUri.Fragment.TrimStart('#'); - if (linkReference.Redirects is not null && linkReference.Redirects.TryGetValue(lookupPath, out var redirect)) + if (!string.IsNullOrEmpty(originalFragment) && redirectRule.Many is { Length: > 0 }) { - var targets = (redirect.Many ?? []) - .Select(r => r) - .Concat([redirect]) - .Where(s => !string.IsNullOrEmpty(s.To)) - .ToArray(); + foreach (var subRule in redirectRule.Many) + { + if (string.IsNullOrEmpty(subRule.To)) + continue; + + if (subRule.Anchors is null || subRule.Anchors.Count == 0) + continue; - return ResolveLinkRedirect(targets, errorEmitter, linkReference, crossLinkUri, ref lookupPath, out link, ref lookupFragment); + if (subRule.Anchors.TryGetValue("!", out _)) + return FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, subRule.To, null, fetchedCrossLinks, out resolvedUri); + if (subRule.Anchors.TryGetValue(originalFragment, out var mappedAnchor)) + return FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, subRule.To, mappedAnchor, fetchedCrossLinks, out resolvedUri); + } } - if (linkReference.Links.TryGetValue(lookupPath, out link)) + string? finalTargetFragment = null; + + if (!string.IsNullOrEmpty(originalFragment)) { - lookupFragment = crossLinkUri.Fragment; - return true; + if (redirectRule.Anchors?.TryGetValue("!", out _) ?? false) + finalTargetFragment = null; + else if (redirectRule.Anchors?.TryGetValue(originalFragment, out var mappedAnchor) ?? false) + finalTargetFragment = mappedAnchor; + else if (redirectRule.Anchors is null || redirectRule.Anchors.Count == 0) + finalTargetFragment = originalFragment; + else + { + errorEmitter($"Redirect rule for '{originalLookupPath}' in '{originalCrossLinkUri.Scheme}' found, but top-level rule did not handle anchor '#{originalFragment}'."); + return false; + } } - var linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{crossLinkUri.Scheme}/main/links.json"; - if (crossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var linkIndexEntry)) - linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/{linkIndexEntry.Path}"; - - errorEmitter($"'{lookupPath}' is not a valid link in the '{crossLinkUri.Scheme}' cross link index: {linksJson}"); - return false; + return string.IsNullOrEmpty(redirectRule.To) + ? FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, originalLookupPath, finalTargetFragment, fetchedCrossLinks, out resolvedUri) + : FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, redirectRule.To, finalTargetFragment, fetchedCrossLinks, out resolvedUri); } - private static bool ResolveLinkRedirect( - LinkSingleRedirect[] redirects, + private static bool FinalizeRedirect( Action errorEmitter, - LinkReference linkReference, - Uri crossLinkUri, - ref string lookupPath, out LinkMetadata? link, ref string? lookupFragment) + IUriEnvironmentResolver uriResolver, + Uri originalProcessingUri, + string redirectToPath, + string? targetFragment, + FetchedCrossLinks fetchedCrossLinks, + [NotNullWhen(true)] out Uri? resolvedUri) { - var fragment = crossLinkUri.Fragment.TrimStart('#'); - link = null; - foreach (var redirect in redirects) + resolvedUri = null; + string finalPathForResolver; + + if (Uri.TryCreate(redirectToPath, UriKind.Absolute, out var targetCrossUri) && targetCrossUri.Scheme != "http" && targetCrossUri.Scheme != "https") { - if (string.IsNullOrEmpty(redirect.To)) - continue; - if (!linkReference.Links.TryGetValue(redirect.To, out link)) - continue; + var lookupPath = Path.Combine(targetCrossUri.Host, targetCrossUri.AbsolutePath.TrimStart('/')); + finalPathForResolver = ToTargetUrlPath(lookupPath); - if (string.IsNullOrEmpty(fragment)) - { - lookupPath = redirect.To; - return true; - } + if (!string.IsNullOrEmpty(targetFragment) && targetFragment != "!") + finalPathForResolver += $"#{targetFragment}"; - if (redirect.Anchors is null || redirect.Anchors.Count == 0) + if (!fetchedCrossLinks.LinkReferences.TryGetValue(targetCrossUri.Scheme, out var targetLinkReference)) { - if (redirects.Length > 1) - continue; - lookupPath = redirect.To; - lookupFragment = crossLinkUri.Fragment; - return true; + errorEmitter($"Redirect target '{redirectToPath}' points to repository '{targetCrossUri.Scheme}' for which no links.json was found."); + return false; } - if (redirect.Anchors.TryGetValue("!", out _)) + if (!targetLinkReference.Links.ContainsKey(lookupPath)) { - lookupPath = redirect.To; - lookupFragment = null; - return true; + errorEmitter($"Redirect target '{redirectToPath}' points to file '{lookupPath}' which was not found in repository '{targetCrossUri.Scheme}'s links.json."); + return false; } - if (!redirect.Anchors.TryGetValue(crossLinkUri.Fragment.TrimStart('#'), out var newFragment)) - continue; - - lookupPath = redirect.To; - lookupFragment = newFragment; - return true; + resolvedUri = uriResolver.Resolve(targetCrossUri, finalPathForResolver); // Use targetUri for scheme and base } + else + { + finalPathForResolver = ToTargetUrlPath(redirectToPath); + if (!string.IsNullOrEmpty(targetFragment) && targetFragment != "!") + finalPathForResolver += $"#{targetFragment}"; - var targets = string.Join(", ", redirects.Select(r => r.To)); - var failedLookup = lookupFragment is null ? lookupPath : $"{lookupPath}#{lookupFragment.TrimStart('#')}"; - errorEmitter($"'{failedLookup}' is set a redirect but none of redirect '{targets}' match or exist in links.json."); - return false; + resolvedUri = uriResolver.Resolve(originalProcessingUri, finalPathForResolver); // Use original URI's scheme + } + return true; } private static string ToTargetUrlPath(string lookupPath) diff --git a/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs b/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs index 6b2dbca3d..36e633f9f 100644 --- a/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs @@ -27,26 +27,29 @@ protected override void Write(HtmlRenderer renderer, LinkInline link) var url = link.GetDynamicUrl != null ? link.GetDynamicUrl() : link.Url; + var isCrossLink = (link.GetData("isCrossLink") as bool?) == true; + var isHttpLink = url?.StartsWith("http") ?? false; + _ = renderer.Write(" Date: Thu, 8 May 2025 09:33:54 -0300 Subject: [PATCH 2/4] Removing leniency for asciidocalypse/unpublished links --- docs/contribute/redirects.md | 2 +- .../Links/CrossLinks/CrossLinkResolver.cs | 31 ++++--------------- .../InboundLinks/LinkIndexLinkChecker.cs | 2 +- .../DiagnosticLinkInlineParser.cs | 1 - .../TestCrossLinkResolver.cs | 4 +-- .../Framework/TestCrossLinkResolver.fs | 4 +-- 6 files changed, 12 insertions(+), 32 deletions(-) diff --git a/docs/contribute/redirects.md b/docs/contribute/redirects.md index 995a7a826..5f22b2aab 100644 --- a/docs/contribute/redirects.md +++ b/docs/contribute/redirects.md @@ -51,7 +51,7 @@ redirects: 'removed-anchor': 'testing/redirects/cross-repo-page.md': 'other-repo://reference/section/new-cross-repo-page.md' 'testing/redirects/8th-page.md': - to: 'other-repo://testing/redirects/5th-page.md' + to: 'other-repo://reference/section/new-cross-repo-page.md' anchors: '!' many: - to: 'testing/redirects/second-page.md' diff --git a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs index d39bbec3b..4bd6bb3ca 100644 --- a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs @@ -11,7 +11,7 @@ namespace Elastic.Markdown.Links.CrossLinks; public interface ICrossLinkResolver { Task FetchLinks(Cancel ctx); - bool TryResolve(Action errorEmitter, Action warningEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri); + bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri); IUriEnvironmentResolver UriResolver { get; } } @@ -26,8 +26,8 @@ public async Task FetchLinks(Cancel ctx) return _crossLinks; } - public bool TryResolve(Action errorEmitter, Action warningEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => - TryResolve(errorEmitter, warningEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri); + public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => + TryResolve(errorEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri); public FetchedCrossLinks UpdateLinkReference(string repository, LinkReference linkReference) { @@ -42,7 +42,6 @@ public FetchedCrossLinks UpdateLinkReference(string repository, LinkReference li public static bool TryResolve( Action errorEmitter, - Action warningEmitter, FetchedCrossLinks fetchedCrossLinks, IUriEnvironmentResolver uriResolver, Uri crossLinkUri, @@ -51,28 +50,10 @@ public static bool TryResolve( { resolvedUri = null; - if (crossLinkUri.Scheme.Equals("asciidocalypse") || !fetchedCrossLinks.LinkReferences.TryGetValue(crossLinkUri.Scheme, out var sourceLinkReference)) + if (!fetchedCrossLinks.LinkReferences.TryGetValue(crossLinkUri.Scheme, out var sourceLinkReference)) { - // TODO this is temporary while we wait for all links.json to be published - // Here we just silently rewrite the cross_link to the url - - var declaredRepositories = fetchedCrossLinks.DeclaredRepositories; - if (!declaredRepositories.Contains(crossLinkUri.Scheme)) - { - if (fetchedCrossLinks.FromConfiguration) - errorEmitter($"'{crossLinkUri.Scheme}' is not declared as valid cross link repository in docset.yml under cross_links: '{crossLinkUri}'"); - else - warningEmitter($"'{crossLinkUri.Scheme}' is not yet publishing to the links registry: '{crossLinkUri}'"); - return false; - } - - var lookupPathFallback = (crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/'); - var pathFallback = ToTargetUrlPath(lookupPathFallback); - if (!string.IsNullOrEmpty(crossLinkUri.Fragment)) - pathFallback += crossLinkUri.Fragment; - - resolvedUri = uriResolver.Resolve(crossLinkUri, pathFallback); - return true; + errorEmitter($"'{crossLinkUri.Scheme}' was not found in the cross link index."); + return false; } var originalLookupPath = (crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/'); diff --git a/src/Elastic.Markdown/Links/InboundLinks/LinkIndexLinkChecker.cs b/src/Elastic.Markdown/Links/InboundLinks/LinkIndexLinkChecker.cs index d5a24b0b5..0b35f5902 100644 --- a/src/Elastic.Markdown/Links/InboundLinks/LinkIndexLinkChecker.cs +++ b/src/Elastic.Markdown/Links/InboundLinks/LinkIndexLinkChecker.cs @@ -126,7 +126,7 @@ RepositoryFilter filter } collector.EmitError(repository, s); - }, s => collector.EmitWarning(linksJson, s), uri, out _); + }, uri, out _); } } // non-strict for now diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index f236ad4ed..e3799c29a 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -181,7 +181,6 @@ private static void ProcessCrossLink(LinkInline link, InlineProcessor processor, if (context.CrossLinkResolver.TryResolve( s => processor.EmitError(link, s), - s => processor.EmitWarning(link, s), uri, out var resolvedUri) ) link.Url = resolvedUri.ToString(); diff --git a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs index 56a439dfa..ba50ec755 100644 --- a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs +++ b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs @@ -68,6 +68,6 @@ public Task FetchLinks(Cancel ctx) return Task.FromResult(_crossLinks); } - public bool TryResolve(Action errorEmitter, Action warningEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => - CrossLinkResolver.TryResolve(errorEmitter, warningEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri); + public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => + CrossLinkResolver.TryResolve(errorEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri); } diff --git a/tests/authoring/Framework/TestCrossLinkResolver.fs b/tests/authoring/Framework/TestCrossLinkResolver.fs index 4b9c9d6d4..6c6ca7f36 100644 --- a/tests/authoring/Framework/TestCrossLinkResolver.fs +++ b/tests/authoring/Framework/TestCrossLinkResolver.fs @@ -86,7 +86,7 @@ type TestCrossLinkResolver (config: ConfigurationFile) = ) Task.FromResult crossLinks - member this.TryResolve(errorEmitter, warningEmitter, crossLinkUri, []resolvedUri : byref) = + member this.TryResolve(errorEmitter, crossLinkUri, []resolvedUri : byref) = let indexEntries = this.LinkReferences.ToDictionary(_.Key, fun (e : KeyValuePair) -> LinkRegistryEntry( Repository = e.Key, @@ -104,6 +104,6 @@ type TestCrossLinkResolver (config: ConfigurationFile) = LinkIndexEntries=indexEntries.ToFrozenDictionary() ) - CrossLinkResolver.TryResolve(errorEmitter, warningEmitter, crossLinks, uriResolver, crossLinkUri, &resolvedUri); + CrossLinkResolver.TryResolve(errorEmitter, crossLinks, uriResolver, crossLinkUri, &resolvedUri); From 9322d4ac1e8b1054c92509b544c90b42a78c8a89 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Fri, 23 May 2025 09:10:26 -0300 Subject: [PATCH 3/4] Fix remaining test errors after the update, and introduce test cases for complex anchor scenarios. --- .../Links/CrossLinks/CrossLinkResolver.cs | 2 +- .../Framework/CrossLinkResolverAssertions.fs | 123 ++++++++++++++++++ .../Inline/CrossLinkRedirectAnchors.fs | 61 +++++++++ tests/authoring/Inline/CrossLinks.fs | 10 +- tests/authoring/authoring.fsproj | 2 + 5 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 tests/authoring/Framework/CrossLinkResolverAssertions.fs create mode 100644 tests/authoring/Inline/CrossLinkRedirectAnchors.fs diff --git a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs index 4bd6bb3ca..a8b5144a6 100644 --- a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs @@ -52,7 +52,7 @@ public static bool TryResolve( if (!fetchedCrossLinks.LinkReferences.TryGetValue(crossLinkUri.Scheme, out var sourceLinkReference)) { - errorEmitter($"'{crossLinkUri.Scheme}' was not found in the cross link index."); + errorEmitter($"'{crossLinkUri.Scheme}' was not found in the cross link index"); return false; } diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs new file mode 100644 index 000000000..068eb99e0 --- /dev/null +++ b/tests/authoring/Framework/CrossLinkResolverAssertions.fs @@ -0,0 +1,123 @@ +// 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 + +namespace authoring + +open System +open System.Collections.Generic +open System.Collections.Frozen +open System.IO.Abstractions.TestingHelpers +open Elastic.Documentation.Diagnostics +open Elastic.Documentation.Links +open Elastic.Markdown.Links.CrossLinks +open Elastic.Documentation +open Swensen.Unquote +open Elastic.Documentation.Configuration.Builder +open authoring + +module CrossLinkResolverAssertions = + + let private parseRedirectsYaml (redirectsYamlContent: string) (collector: IDiagnosticsCollector) = + let mockFileSystem = MockFileSystem() + let fullYaml = sprintf "redirects:\n%s" (redirectsYamlContent.Replace("\r\n", "\n").Replace("\n", "\n ")) + let mockRedirectsFilePath = "mock_redirects.yml" + mockFileSystem.AddFile(mockRedirectsFilePath, MockFileData(fullYaml)) + let mockRedirectsFile = mockFileSystem.FileInfo.New(mockRedirectsFilePath) + + let docContext = + { new IDocumentationContext with + member _.Collector = collector + member _.DocumentationSourceDirectory = mockFileSystem.DirectoryInfo.New("/docs") + member _.Git = GitCheckoutInformation.Unavailable + member _.ReadFileSystem = mockFileSystem + member _.WriteFileSystem = mockFileSystem + member _.ConfigurationPath = mockFileSystem.FileInfo.New("mock_docset.yml") + } + let redirectFileParser = RedirectFile(mockRedirectsFile, docContext) + redirectFileParser.Redirects + + // Helper to create FetchedCrossLinks + let private createFetchedCrossLinks (redirectsYamlSnippet: string) (linksData: IDictionary) repoName = + let collector = TestDiagnosticsCollector() :> IDiagnosticsCollector + let redirectRules = parseRedirectsYaml redirectsYamlSnippet collector + + if collector.Errors > 0 then + failwithf $"Failed to parse redirects YAML: %A{collector}" + + let linkReference = + LinkReference( + Origin = GitCheckoutInformation.Unavailable, + UrlPathPrefix = null, + Links = Dictionary(linksData), + CrossLinks = Array.empty, + Redirects = redirectRules + ) + + let declaredRepos = HashSet() + declaredRepos.Add(repoName) |> ignore + + FetchedCrossLinks( + DeclaredRepositories = declaredRepos, + LinkReferences = FrozenDictionary.ToFrozenDictionary(dict [repoName, linkReference]), + FromConfiguration = true, + LinkIndexEntries = FrozenDictionary.Empty + ) + + let private redirectsYaml = """ + # test scenario 1 + 'testing/redirects/multi-topic-page-1-old.md': + to: 'testing/redirects/multi-topic-page-1-new-anchorless.md' + anchors: { "!": null } + many: + - to: 'testing/redirects/multi-topic-page-1-new-topic-a-subpage.md' + anchors: {'topic-a-intro': null, 'topic-a-details': 'details-anchor'} + - to: 'testing/redirects/multi-topic-page-1-new-topic-b-subpage.md' + anchors: {'topic-b-main': 'main-anchor'} + - to: 'testing/redirects/multi-topic-page-1-old.md' + anchors: {'topic-c-main': 'topic-c-main'} + # test scenario 2 + 'testing/redirects/multi-topic-page-2-old.md': + to: 'testing/redirects/multi-topic-page-2-old.md' + anchors: {} # This means pass through any anchor for the default 'to' + many: + - to: 'testing/redirects/multi-topic-page-2-new-topic-a-subpage.md' + anchors: {'topic-a-intro': 'introduction', 'topic-a-details': null} + - to: 'testing/redirects/multi-topic-page-2-new-topic-b-subpage.md' + anchors: {'topic-b-main': 'summary', 'topic-b-config': null} +""" + + let private mockLinksData = + dict [ + ("testing/redirects/multi-topic-page-1-new-anchorless.md", LinkMetadata(Anchors = null, Hidden = false)) + ("testing/redirects/multi-topic-page-1-new-topic-a-subpage.md", LinkMetadata(Anchors = [| "details-anchor"; "introduction" |], Hidden = false)) + ("testing/redirects/multi-topic-page-1-new-topic-b-subpage.md", LinkMetadata(Anchors = [| "main-anchor" |], Hidden = false)) + ("testing/redirects/multi-topic-page-1-old.md", LinkMetadata(Anchors = [| "topic-c-main"; "unmatched-anchor" |], Hidden = false)) + ("testing/redirects/multi-topic-page-2-old.md", LinkMetadata(Anchors = [| "unmatched-anchor"; "topic-c-main"; "topic-a-intro"; "topic-a-details"; "topic-b-main"; "topic-b-config" |], Hidden = false)) + ("testing/redirects/multi-topic-page-2-new-topic-a-subpage.md", LinkMetadata(Anchors = [| "introduction"; "summary" |], Hidden = false)) + ("testing/redirects/multi-topic-page-2-new-topic-b-subpage.md", LinkMetadata(Anchors = [| "summary" |], Hidden = false)) + ] :> IDictionary<_,_> + + + let private repoName = "docs-content" + let private fetchedLinks = createFetchedCrossLinks redirectsYaml mockLinksData repoName + let private uriResolver = IsolatedBuildEnvironmentUriResolver() :> IUriEnvironmentResolver + let private baseExpectedUrl = $"https://docs-v3-preview.elastic.dev/elastic/{repoName}/tree/main" + + let resolvesTo (inputUrl: string) (expectedPathWithOptionalAnchor: string) = + let mutable errors = List.empty + let errorEmitter (msg: string) = errors <- msg :: errors + let inputUri = Uri(inputUrl) + let mutable resolvedUri : Uri = Unchecked.defaultof + + let success = CrossLinkResolver.TryResolve(Action<_>(errorEmitter), fetchedLinks, uriResolver, inputUri, &resolvedUri) + + if not errors.IsEmpty then + failwithf $"Resolution for '%s{inputUrl}' failed with errors: %A{errors}" + + test <@ success @> + match box resolvedUri with + | null -> failwithf $"Resolved URI was null for input '%s{inputUrl}' even though TryResolve returned true." + | _ -> + let expectedFullUrl = baseExpectedUrl + expectedPathWithOptionalAnchor + test <@ resolvedUri.ToString() = expectedFullUrl @> \ No newline at end of file diff --git a/tests/authoring/Inline/CrossLinkRedirectAnchors.fs b/tests/authoring/Inline/CrossLinkRedirectAnchors.fs new file mode 100644 index 000000000..4477402d8 --- /dev/null +++ b/tests/authoring/Inline/CrossLinkRedirectAnchors.fs @@ -0,0 +1,61 @@ +// 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 ``inline elements``.``complex anchors`` + +open Xunit +open authoring.CrossLinkResolverAssertions + +type ``scenario 1``() = + + [] + let ``No anchor redirects to new-anchorless page``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-1-old.md") "/testing/redirects/multi-topic-page-1-new-anchorless" + + [] + let ``Unmatched anchor for '!' rule redirects to new-anchorless page and drops anchor``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-1-old.md#unmatched-anchor") "/testing/redirects/multi-topic-page-1-new-anchorless" + + [] + let ``topic-a-intro redirects to topic-a-subpage and drops anchor (null target)``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-1-old.md#topic-a-intro") "/testing/redirects/multi-topic-page-1-new-topic-a-subpage" + + [] + let ``topic-a-details redirects to topic-a-subpage with new anchor``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-1-old.md#topic-a-details") "/testing/redirects/multi-topic-page-1-new-topic-a-subpage#details-anchor" + + [] + let ``topic-b-main redirects to topic-b-subpage with new anchor``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-1-old.md#topic-b-main") "/testing/redirects/multi-topic-page-1-new-topic-b-subpage#main-anchor" + + [] + let ``topic-c-main redirects to old page and keeps anchor``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-1-old.md#topic-c-main") "/testing/redirects/multi-topic-page-1-old#topic-c-main" + + +type ``Scenario 2``() = + + [] + let ``No anchor redirects to old page (self)``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-2-old.md") "/testing/redirects/multi-topic-page-2-old" + + [] + let ``Unmatched anchor for '{}' rule redirects to old page (self) and keeps anchor``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-2-old.md#unmatched-anchor") "/testing/redirects/multi-topic-page-2-old#unmatched-anchor" + + [] + let ``topic-a-intro redirects to topic-a-subpage with new anchor``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-2-old.md#topic-a-intro") "/testing/redirects/multi-topic-page-2-new-topic-a-subpage#introduction" + + [] + let ``topic-a-details redirects to topic-a-subpage and drops anchor (null target)``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-2-old.md#topic-a-details") "/testing/redirects/multi-topic-page-2-new-topic-a-subpage" + + [] + let ``topic-b-main redirects to topic-b-subpage with new anchor``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-2-old.md#topic-b-main") "/testing/redirects/multi-topic-page-2-new-topic-b-subpage#summary" + + [] + let ``topic-b-config redirects to topic-b-subpage and drops anchor (null target)``() = + resolvesTo ("docs-content://testing/redirects/multi-topic-page-2-old.md#topic-b-config") "/testing/redirects/multi-topic-page-2-new-topic-b-subpage" \ No newline at end of file diff --git a/tests/authoring/Inline/CrossLinks.fs b/tests/authoring/Inline/CrossLinks.fs index 302b74c86..dc56dfa7d 100644 --- a/tests/authoring/Inline/CrossLinks.fs +++ b/tests/authoring/Inline/CrossLinks.fs @@ -38,7 +38,7 @@ type ``error when using wrong scheme`` () = [] let ``error on bad scheme`` () = markdown - |> hasError "'docs-x' is not declared as valid cross link repository in docset.yml under cross_links" + |> hasError "'docs-x' was not found in the cross link index" [] let ``has no warning`` () = markdown |> hasNoWarnings @@ -89,14 +89,14 @@ type ``link to repository that does not resolve yet`` () = let ``validate HTML`` () = markdown |> convertsToHtml """

+ href="elasticsearch:/index.md"> Elasticsearch Documentation

""" [] - let ``has no errors`` () = markdown |> hasNoErrors + let ``error when not found in links.json`` () = markdown |> hasError("'elasticsearch' was not found in the cross link index") [] let ``has no warning`` () = markdown |> hasNoWarnings @@ -133,14 +133,14 @@ type ``link to repository that does not resolve yet using double slashes`` () = let ``validate HTML`` () = markdown |> convertsToHtml """

+ href="elasticsearch://index.md"> Elasticsearch Documentation

""" [] - let ``has no errors`` () = markdown |> hasNoErrors + let ``error when not found in links.json`` () = markdown |> hasError("'elasticsearch' was not found in the cross link index") [] let ``has no warning`` () = markdown |> hasNoWarnings \ No newline at end of file diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index aa289193f..e17cc844e 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -38,6 +38,7 @@ + @@ -47,6 +48,7 @@ + From 28093ab0a7bf2a34135d4acca51c35b665aa9999 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Fri, 23 May 2025 09:57:49 -0300 Subject: [PATCH 4/4] Further explain complex anchoring scenarios in the docs. --- docs/contribute/redirects.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/contribute/redirects.md b/docs/contribute/redirects.md index 5f22b2aab..3857ace38 100644 --- a/docs/contribute/redirects.md +++ b/docs/contribute/redirects.md @@ -138,9 +138,10 @@ redirects: ```yaml redirects: + # In this first scenario, the default redirection target remains the same page, with anchors being preserved. + # Omitting the ``anchors`` tag or explicitly setting it as empty are both supported. 'testing/redirects/8th-page.md': to: 'testing/redirects/8th-page.md' - anchors: {} many: - to: 'testing/redirects/second-page.md' anchors: @@ -149,6 +150,7 @@ redirects: anchors: 'item-b': + # In this scenario, the default redirection target is a different page, and anchors are dropped. 'testing/redirects/deleted-page.md': to: 'testing/redirects/5th-page.md' anchors: '!'