diff --git a/docs/contribute/redirects.md b/docs/contribute/redirects.md
index 7cd26603e..3857ace38 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://reference/section/new-cross-repo-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,49 @@ 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:
+  # 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'
+    many:
+      - to: 'testing/redirects/second-page.md'
+        anchors:
+          'item-a': 'yy'
+      - to: 'testing/redirects/third-page.md'
+        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: '!'
+    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 f3f6b918a..8a15c30ec 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<string, string?>? 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 c9eb1ba40..c633fdbfd 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;
@@ -12,7 +11,7 @@ namespace Elastic.Markdown.Links.CrossLinks;
 public interface ICrossLinkResolver
 {
 	Task<FetchedCrossLinks> FetchLinks(Cancel ctx);
-	bool TryResolve(Action<string> errorEmitter, Action<string> warningEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri);
+	bool TryResolve(Action<string> errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri);
 	IUriEnvironmentResolver UriResolver { get; }
 }
 
@@ -27,8 +26,8 @@ public async Task<FetchedCrossLinks> FetchLinks(Cancel ctx)
 		return _crossLinks;
 	}
 
-	public bool TryResolve(Action<string> errorEmitter, Action<string> warningEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) =>
-		TryResolve(errorEmitter, warningEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri);
+	public bool TryResolve(Action<string> errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) =>
+		TryResolve(errorEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri);
 
 	public FetchedCrossLinks UpdateLinkReference(string repository, RepositoryLinks repositoryLinks)
 	{
@@ -43,7 +42,6 @@ public FetchedCrossLinks UpdateLinkReference(string repository, RepositoryLinks
 
 	public static bool TryResolve(
 		Action<string> errorEmitter,
-		Action<string> warningEmitter,
 		FetchedCrossLinks fetchedCrossLinks,
 		IUriEnvironmentResolver uriResolver,
 		Uri crossLinkUri,
@@ -51,155 +49,154 @@ 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 (!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}'");
+			errorEmitter($"'{crossLinkUri.Scheme}' was not found in the cross link index");
 			return false;
 		}
 
-		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<string> errorEmitter,
+	private static bool ResolveDirectLink(Action<string> errorEmitter,
 		IUriEnvironmentResolver uriResolver,
-		FetchedCrossLinks fetchedCrossLinks,
-		RepositoryLinks repositoryLinks,
 		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, repositoryLinks, 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<string> errorEmitter,
-		FetchedCrossLinks crossLinks,
-		RepositoryLinks repositoryLinks,
-		Uri crossLinkUri,
-		ref string lookupPath,
-		[NotNullWhen(true)] out LinkMetadata? link,
-		[NotNullWhen(true)] out string? lookupFragment)
+	private static bool ResolveRedirect(
+		Action<string> 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 (repositoryLinks.Redirects is not null && repositoryLinks.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;
 
-			return ResolveLinkRedirect(targets, errorEmitter, repositoryLinks, crossLinkUri, ref lookupPath, out link, ref lookupFragment);
+				if (subRule.Anchors is null || subRule.Anchors.Count == 0)
+					continue;
+
+				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 (repositoryLinks.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<string> errorEmitter,
-		RepositoryLinks repositoryLinks,
-		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 (!repositoryLinks.Links.TryGetValue(redirect.To, out link))
-				continue;
+			var lookupPath = $"{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/Links/InboundLinks/LinkIndexLinkChecker.cs b/src/Elastic.Markdown/Links/InboundLinks/LinkIndexLinkChecker.cs
index 34cdc704a..c78917d1a 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 59231fc9d..ae3f7c064 100644
--- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs
+++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs
@@ -184,7 +184,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/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("<a href=\"");
 			_ = renderer.WriteEscapeUrl(url);
 			_ = renderer.Write('"');
 			_ = renderer.WriteAttributes(link);
 
-
 			if (link.Url?.StartsWith('/') == true)
 			{
 				var currentRootNavigation = link.GetData(nameof(MarkdownFile.NavigationRoot)) as INavigationGroup;
 				var targetRootNavigation = link.GetData($"Target{nameof(MarkdownFile.NavigationRoot)}") as INavigationGroup;
+				var hasSameTopLevelGroup = !isCrossLink && (currentRootNavigation?.Id == targetRootNavigation?.Id);
 				_ = renderer.Write(" hx-get=\"");
 				_ = renderer.WriteEscapeUrl(url);
 				_ = renderer.Write('"');
-				_ = renderer.Write($" hx-select-oob=\"{Htmx.GetHxSelectOob(currentRootNavigation?.Id == targetRootNavigation?.Id)}\"");
+				_ = renderer.Write($" hx-select-oob=\"{Htmx.GetHxSelectOob(hasSameTopLevelGroup)}\"");
 				_ = renderer.Write($" hx-swap=\"{Htmx.HxSwap}\"");
 				_ = renderer.Write($" hx-push-url=\"{Htmx.HxPushUrl}\"");
 				_ = renderer.Write($" hx-indicator=\"{Htmx.HxIndicator}\"");
 				_ = renderer.Write($" preload=\"{Htmx.Preload}\"");
 			}
-			else if (link.Url?.StartsWith("http") == true && (link.GetData("isCrossLink") as bool?) == false)
+			if (isHttpLink && !isCrossLink)
 			{
 				_ = renderer.Write(" target=\"_blank\"");
 				_ = renderer.Write(" rel=\"noopener noreferrer\"");
diff --git a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs
index 85fabd552..2e801781b 100644
--- a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs
+++ b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs
@@ -68,6 +68,6 @@ public Task<FetchedCrossLinks> FetchLinks(Cancel ctx)
 		return Task.FromResult(_crossLinks);
 	}
 
-	public bool TryResolve(Action<string> errorEmitter, Action<string> warningEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) =>
-		CrossLinkResolver.TryResolve(errorEmitter, warningEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri);
+	public bool TryResolve(Action<string> errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) =>
+		CrossLinkResolver.TryResolve(errorEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri);
 }
diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs
new file mode 100644
index 000000000..7c6b851c7
--- /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
+
+    let private createFetchedCrossLinks (redirectsYamlSnippet: string) (linksData: IDictionary<string, LinkMetadata>) repoName =
+        let collector = TestDiagnosticsCollector() :> IDiagnosticsCollector
+        let redirectRules = parseRedirectsYaml redirectsYamlSnippet collector
+
+        if collector.Errors > 0 then
+            failwithf $"Failed to parse redirects YAML: %A{collector}"
+
+        let repositoryLinks =
+            RepositoryLinks(
+              Origin = GitCheckoutInformation.Unavailable,
+              UrlPathPrefix = null,
+              Links = Dictionary(linksData),
+              CrossLinks = Array.empty<string>,
+              Redirects = redirectRules
+            )
+
+        let declaredRepos = HashSet<string>()
+        declaredRepos.Add(repoName) |> ignore
+
+        FetchedCrossLinks(
+            DeclaredRepositories = declaredRepos,
+            LinkReferences = FrozenDictionary.ToFrozenDictionary(dict [repoName, repositoryLinks]),
+            FromConfiguration = true,
+            LinkIndexEntries = FrozenDictionary<string, LinkRegistryEntry>.Empty
+        )
+
+    // language=yaml
+    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<Uri>
+
+        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/Framework/TestCrossLinkResolver.fs b/tests/authoring/Framework/TestCrossLinkResolver.fs
index 511d44a8d..26a2ac456 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, [<Out>]resolvedUri : byref<Uri|null>) =
+        member this.TryResolve(errorEmitter, crossLinkUri, [<Out>]resolvedUri : byref<Uri|null>) =
             let indexEntries =
                 this.LinkReferences.ToDictionary(_.Key, fun (e : KeyValuePair<string, RepositoryLinks>) -> 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);
 
 
diff --git a/tests/authoring/Inline/CrossLinkRedirectAnchors.fs b/tests/authoring/Inline/CrossLinkRedirectAnchors.fs
new file mode 100644
index 000000000..1e0be3ea4
--- /dev/null
+++ b/tests/authoring/Inline/CrossLinkRedirectAnchors.fs
@@ -0,0 +1,85 @@
+// 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: complex redirect mapping with anchor dropping for fallback redirects``() =
+
+    [<Fact>]
+    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"
+
+    [<Fact>]
+    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"
+
+    [<Fact>]
+    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"
+
+    [<Fact>]
+    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"
+
+    [<Fact>]
+    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"
+
+    [<Fact>]
+    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: complex redirect mapping with anchor passing for fallback redirects``() =
+
+    [<Fact>]
+    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"
+
+    [<Fact>]
+    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"
+
+    [<Fact>]
+    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"
+
+    [<Fact>]
+    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"
+
+    [<Fact>]
+    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"
+
+    [<Fact>]
+    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`` () =
     [<Fact>]
     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"
 
     [<Fact>]
     let ``has no warning`` () = markdown |> hasNoWarnings
@@ -89,14 +89,14 @@ type ``link to repository that does not resolve yet`` () =
     let ``validate HTML`` () =
         markdown |> convertsToHtml """
             <p><a
-                href="https://docs-v3-preview.elastic.dev/elastic/elasticsearch/tree/main/">
+                href="elasticsearch:/index.md">
                 Elasticsearch Documentation
                 </a>
             </p>
         """
 
     [<Fact>]
-    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")
 
     [<Fact>]
     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 """
             <p><a
-                href="https://docs-v3-preview.elastic.dev/elastic/elasticsearch/tree/main/">
+                href="elasticsearch://index.md">
                 Elasticsearch Documentation
                 </a>
             </p>
         """
 
     [<Fact>]
-    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")
 
     [<Fact>]
     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 2520c7227..7a1288ba0 100644
--- a/tests/authoring/authoring.fsproj
+++ b/tests/authoring/authoring.fsproj
@@ -38,6 +38,7 @@
     <Compile Include="Framework\HtmlAssertions.fs"/>
     <Compile Include="Framework\ErrorCollectorAssertions.fs"/>
     <Compile Include="Framework\MarkdownDocumentAssertions.fs"/>
+    <Compile Include="Framework\CrossLinkResolverAssertions.fs" />
     <Compile Include="Inline\Substitutions.fs"/>
     <Compile Include="Inline\InlineAnchors.fs"/>
     <Compile Include="Inline\Comments.fs"/>
@@ -47,6 +48,7 @@
     <Compile Include="Inline\CrossLinks.fs" />
     <Compile Include="Inline\CrossLinksRedirects.fs" />
     <Compile Include="Inline\RelativeLinks.fs" />
+    <Compile Include="Inline\CrossLinkRedirectAnchors.fs" />
     <Compile Include="Container\DefinitionLists.fs"/>
     <Compile Include="Generator\LinkReferenceFile.fs"/>
     <Compile Include="Blocks\CodeBlocks\CodeBlocks.fs"/>