Skip to content

Support cross-repository redirects and specialized anchor scenarios #1227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/contribute/redirects.md
Original file line number Diff line number Diff line change
@@ -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':
Copy link
Preview

Copilot AI May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The anchor mapping for 'item-b' is empty, which could be unintentional. Confirm whether this is the desired behavior or if an explicit null/empty value should be specified for clarity.

Suggested change
'item-b':
'item-b': '!'

Copilot uses AI. Check for mistakes.



```

### 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"
```
8 changes: 8 additions & 0 deletions src/Elastic.Markdown/IO/DocumentationSet.cs
Original file line number Diff line number Diff line change
@@ -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);

205 changes: 101 additions & 104 deletions src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs
Original file line number Diff line number Diff line change
@@ -4,15 +4,14 @@

using System.Collections.Frozen;
using System.Diagnostics.CodeAnalysis;
using Elastic.Documentation;
using Elastic.Documentation.Links;

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,163 +42,161 @@ public FetchedCrossLinks UpdateLinkReference(string repository, RepositoryLinks

public static bool TryResolve(
Action<string> errorEmitter,
Action<string> warningEmitter,
FetchedCrossLinks fetchedCrossLinks,
IUriEnvironmentResolver uriResolver,
Uri crossLinkUri,
[NotNullWhen(true)] out Uri? resolvedUri
)
{
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 = Path.Combine(targetCrossUri.Host, targetCrossUri.AbsolutePath.TrimStart('/'));
Copy link
Preview

Copilot AI May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Path.Combine for URL construction might introduce platform-specific path separators. Consider using string concatenation or a URI-specific method to ensure consistent forward slash formatting in URLs.

Suggested change
var lookupPath = Path.Combine(targetCrossUri.Host, targetCrossUri.AbsolutePath.TrimStart('/'));
var lookupPath = $"{targetCrossUri.Host}/{targetCrossUri.AbsolutePath.TrimStart('/')}";

Copilot uses AI. Check for mistakes.

Sorry, something went wrong.

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)
Original file line number Diff line number Diff line change
@@ -126,7 +126,7 @@ RepositoryFilter filter
}

collector.EmitError(repository, s);
}, s => collector.EmitWarning(linksJson, s), uri, out _);
}, uri, out _);
}
}
// non-strict for now
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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\"");
4 changes: 2 additions & 2 deletions tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs
Original file line number Diff line number Diff line change
@@ -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);
}
122 changes: 122 additions & 0 deletions tests/authoring/Framework/CrossLinkResolverAssertions.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// 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
)

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 @>
4 changes: 2 additions & 2 deletions tests/authoring/Framework/TestCrossLinkResolver.fs
Original file line number Diff line number Diff line change
@@ -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);


61 changes: 61 additions & 0 deletions tests/authoring/Inline/CrossLinkRedirectAnchors.fs
Original file line number Diff line number Diff line change
@@ -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``() =

[<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``() =

[<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"
10 changes: 5 additions & 5 deletions tests/authoring/Inline/CrossLinks.fs
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions tests/authoring/authoring.fsproj
Original file line number Diff line number Diff line change
@@ -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"/>