-
Notifications
You must be signed in to change notification settings - Fork 16
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
cotti
wants to merge
5
commits into
main
Choose a base branch
from
feature/redirect_extensions
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
62fbf84
Support cross-repository redirects and specialized anchor scenarios
cotti f29bacf
Removing leniency for asciidocalypse/unpublished links
cotti 9322d4a
Fix remaining test errors after the update, and introduce test cases …
cotti a9075eb
Resolve merging conflicts from origin/main
cotti 28093ab
Further explain complex anchoring scenarios in the docs.
cotti File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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('/')); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||
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) | ||||||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
Copilot uses AI. Check for mistakes.