Skip to content

Commit 1fa284a

Browse files
authored
Support cross-repository redirects and specialized anchor scenarios (#1227)
* Support cross-repository redirects and specialized anchor scenarios * Removing leniency for asciidocalypse/unpublished links
1 parent 9d4015e commit 1fa284a

File tree

12 files changed

+394
-118
lines changed

12 files changed

+394
-118
lines changed

docs/contribute/redirects.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ redirects:
4949
'testing/redirects/third-page.md':
5050
anchors:
5151
'removed-anchor':
52+
'testing/redirects/cross-repo-page.md': 'other-repo://reference/section/new-cross-repo-page.md'
53+
'testing/redirects/8th-page.md':
54+
to: 'other-repo://reference/section/new-cross-repo-page.md'
55+
anchors: '!'
56+
many:
57+
- to: 'testing/redirects/second-page.md'
58+
anchors:
59+
'item-a': 'yy'
60+
- to: 'testing/redirects/third-page.md'
61+
anchors:
62+
'item-b':
63+
64+
5265
```
5366

5467
### Redirect preserving all anchors
@@ -104,3 +117,49 @@ redirects:
104117
'old-anchor': 'active-anchor'
105118
'removed-anchor':
106119
```
120+
121+
### Redirecting to other repositories
122+
123+
It is possible to redirect to other repositories. The syntax is the same as when linking on documentation sets:
124+
125+
* 'other-repo://reference/section/new-cross-repo-page.md'
126+
127+
```yaml
128+
redirects:
129+
'testing/redirects/cross-repo-page.md': 'other-repo://reference/section/new-cross-repo-page.md'
130+
```
131+
132+
### Managing complex scenarios with anchors
133+
134+
* `to`, `anchor` and `many` can be used together to support more complex scenarios.
135+
* Setting `to` at the top level determines the default case, which can be used for partial redirects.
136+
* Cross-repository links are supported, with the same syntax as in the previous example.
137+
* The existing rules for `anchors` also apply here. To define a catch-all redirect, use `{}`.
138+
139+
```yaml
140+
redirects:
141+
# In this first scenario, the default redirection target remains the same page, with anchors being preserved.
142+
# Omitting the ``anchors`` tag or explicitly setting it as empty are both supported.
143+
'testing/redirects/8th-page.md':
144+
to: 'testing/redirects/8th-page.md'
145+
many:
146+
- to: 'testing/redirects/second-page.md'
147+
anchors:
148+
'item-a': 'yy'
149+
- to: 'testing/redirects/third-page.md'
150+
anchors:
151+
'item-b':
152+
153+
# In this scenario, the default redirection target is a different page, and anchors are dropped.
154+
'testing/redirects/deleted-page.md':
155+
to: 'testing/redirects/5th-page.md'
156+
anchors: '!'
157+
many:
158+
- to: "testing/redirects/second-page.md"
159+
anchors:
160+
"aa": "zz"
161+
"removed-anchor":
162+
- to: "other-repo://reference/section/partial-content.md"
163+
anchors:
164+
"bb": "yy"
165+
```

src/Elastic.Markdown/IO/DocumentationSet.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,14 @@ private void ValidateRedirectsExists()
256256

257257
void ValidateExists(string from, string to, IReadOnlyDictionary<string, string?>? valueAnchors)
258258
{
259+
if (to.Contains("://"))
260+
{
261+
if (!Uri.TryCreate(to, UriKind.Absolute, out _))
262+
Context.EmitError(Configuration.SourceFile, $"Redirect {from} points to {to} which is not a valid URI");
263+
264+
return;
265+
}
266+
259267
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
260268
to = to.Replace('/', Path.DirectorySeparatorChar);
261269

src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs

Lines changed: 101 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@
44

55
using System.Collections.Frozen;
66
using System.Diagnostics.CodeAnalysis;
7-
using Elastic.Documentation;
87
using Elastic.Documentation.Links;
98

109
namespace Elastic.Markdown.Links.CrossLinks;
1110

1211
public interface ICrossLinkResolver
1312
{
1413
Task<FetchedCrossLinks> FetchLinks(Cancel ctx);
15-
bool TryResolve(Action<string> errorEmitter, Action<string> warningEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri);
14+
bool TryResolve(Action<string> errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri);
1615
IUriEnvironmentResolver UriResolver { get; }
1716
}
1817

@@ -27,8 +26,8 @@ public async Task<FetchedCrossLinks> FetchLinks(Cancel ctx)
2726
return _crossLinks;
2827
}
2928

30-
public bool TryResolve(Action<string> errorEmitter, Action<string> warningEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) =>
31-
TryResolve(errorEmitter, warningEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri);
29+
public bool TryResolve(Action<string> errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) =>
30+
TryResolve(errorEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri);
3231

3332
public FetchedCrossLinks UpdateLinkReference(string repository, RepositoryLinks repositoryLinks)
3433
{
@@ -43,163 +42,161 @@ public FetchedCrossLinks UpdateLinkReference(string repository, RepositoryLinks
4342

4443
public static bool TryResolve(
4544
Action<string> errorEmitter,
46-
Action<string> warningEmitter,
4745
FetchedCrossLinks fetchedCrossLinks,
4846
IUriEnvironmentResolver uriResolver,
4947
Uri crossLinkUri,
5048
[NotNullWhen(true)] out Uri? resolvedUri
5149
)
5250
{
5351
resolvedUri = null;
54-
var lookup = fetchedCrossLinks.LinkReferences;
55-
if (crossLinkUri.Scheme != "asciidocalypse" && lookup.TryGetValue(crossLinkUri.Scheme, out var linkReference))
56-
return TryFullyValidate(errorEmitter, uriResolver, fetchedCrossLinks, linkReference, crossLinkUri, out resolvedUri);
5752

58-
// TODO this is temporary while we wait for all links.json to be published
59-
// Here we just silently rewrite the cross_link to the url
60-
61-
var declaredRepositories = fetchedCrossLinks.DeclaredRepositories;
62-
if (!declaredRepositories.Contains(crossLinkUri.Scheme))
53+
if (!fetchedCrossLinks.LinkReferences.TryGetValue(crossLinkUri.Scheme, out var sourceLinkReference))
6354
{
64-
if (fetchedCrossLinks.FromConfiguration)
65-
errorEmitter($"'{crossLinkUri.Scheme}' is not declared as valid cross link repository in docset.yml under cross_links: '{crossLinkUri}'");
66-
else
67-
warningEmitter($"'{crossLinkUri.Scheme}' is not yet publishing to the links registry: '{crossLinkUri}'");
55+
errorEmitter($"'{crossLinkUri.Scheme}' was not found in the cross link index");
6856
return false;
6957
}
7058

71-
var lookupPath = (crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/');
72-
var path = ToTargetUrlPath(lookupPath);
73-
if (!string.IsNullOrEmpty(crossLinkUri.Fragment))
74-
path += crossLinkUri.Fragment;
59+
var originalLookupPath = (crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/');
60+
if (string.IsNullOrEmpty(originalLookupPath) && crossLinkUri.Host.EndsWith(".md"))
61+
originalLookupPath = crossLinkUri.Host;
7562

76-
resolvedUri = uriResolver.Resolve(crossLinkUri, path);
77-
return true;
63+
if (sourceLinkReference.Redirects is not null && sourceLinkReference.Redirects.TryGetValue(originalLookupPath, out var redirectRule))
64+
return ResolveRedirect(errorEmitter, uriResolver, crossLinkUri, redirectRule, originalLookupPath, fetchedCrossLinks, out resolvedUri);
65+
66+
if (sourceLinkReference.Links.TryGetValue(originalLookupPath, out var directLinkMetadata))
67+
return ResolveDirectLink(errorEmitter, uriResolver, crossLinkUri, originalLookupPath, directLinkMetadata, out resolvedUri);
68+
69+
70+
var linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{crossLinkUri.Scheme}/main/links.json";
71+
if (fetchedCrossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var indexEntry))
72+
linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/{indexEntry.Path}";
73+
74+
errorEmitter($"'{originalLookupPath}' is not a valid link in the '{crossLinkUri.Scheme}' cross link index: {linksJson}");
75+
resolvedUri = null;
76+
return false;
7877
}
7978

80-
private static bool TryFullyValidate(Action<string> errorEmitter,
79+
private static bool ResolveDirectLink(Action<string> errorEmitter,
8180
IUriEnvironmentResolver uriResolver,
82-
FetchedCrossLinks fetchedCrossLinks,
83-
RepositoryLinks repositoryLinks,
8481
Uri crossLinkUri,
82+
string lookupPath,
83+
LinkMetadata linkMetadata,
8584
[NotNullWhen(true)] out Uri? resolvedUri)
8685
{
8786
resolvedUri = null;
88-
var lookupPath = (crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/');
89-
if (string.IsNullOrEmpty(lookupPath) && crossLinkUri.Host.EndsWith(".md"))
90-
lookupPath = crossLinkUri.Host;
91-
92-
if (!LookupLink(errorEmitter, fetchedCrossLinks, repositoryLinks, crossLinkUri, ref lookupPath, out var link, out var lookupFragment))
93-
return false;
94-
95-
var path = ToTargetUrlPath(lookupPath);
87+
var lookupFragment = crossLinkUri.Fragment;
88+
var targetUrlPath = ToTargetUrlPath(lookupPath);
9689

9790
if (!string.IsNullOrEmpty(lookupFragment))
9891
{
99-
if (link.Anchors is null)
100-
{
101-
errorEmitter($"'{lookupPath}' does not have any anchors so linking to '{crossLinkUri.Fragment}' is impossible.");
102-
return false;
103-
}
104-
105-
if (!link.Anchors.Contains(lookupFragment.TrimStart('#')))
92+
var anchor = lookupFragment.TrimStart('#');
93+
if (linkMetadata.Anchors is null || !linkMetadata.Anchors.Contains(anchor))
10694
{
10795
errorEmitter($"'{lookupPath}' has no anchor named: '{lookupFragment}'.");
10896
return false;
10997
}
11098

111-
path += "#" + lookupFragment.TrimStart('#');
99+
targetUrlPath += lookupFragment;
112100
}
113101

114-
resolvedUri = uriResolver.Resolve(crossLinkUri, path);
102+
resolvedUri = uriResolver.Resolve(crossLinkUri, targetUrlPath);
115103
return true;
116104
}
117105

118-
private static bool LookupLink(Action<string> errorEmitter,
119-
FetchedCrossLinks crossLinks,
120-
RepositoryLinks repositoryLinks,
121-
Uri crossLinkUri,
122-
ref string lookupPath,
123-
[NotNullWhen(true)] out LinkMetadata? link,
124-
[NotNullWhen(true)] out string? lookupFragment)
106+
private static bool ResolveRedirect(
107+
Action<string> errorEmitter,
108+
IUriEnvironmentResolver uriResolver,
109+
Uri originalCrossLinkUri,
110+
LinkRedirect redirectRule,
111+
string originalLookupPath,
112+
FetchedCrossLinks fetchedCrossLinks,
113+
[NotNullWhen(true)] out Uri? resolvedUri)
125114
{
126-
lookupFragment = null;
115+
resolvedUri = null;
116+
var originalFragment = originalCrossLinkUri.Fragment.TrimStart('#');
127117

128-
if (repositoryLinks.Redirects is not null && repositoryLinks.Redirects.TryGetValue(lookupPath, out var redirect))
118+
if (!string.IsNullOrEmpty(originalFragment) && redirectRule.Many is { Length: > 0 })
129119
{
130-
var targets = (redirect.Many ?? [])
131-
.Select(r => r)
132-
.Concat([redirect])
133-
.Where(s => !string.IsNullOrEmpty(s.To))
134-
.ToArray();
120+
foreach (var subRule in redirectRule.Many)
121+
{
122+
if (string.IsNullOrEmpty(subRule.To))
123+
continue;
135124

136-
return ResolveLinkRedirect(targets, errorEmitter, repositoryLinks, crossLinkUri, ref lookupPath, out link, ref lookupFragment);
125+
if (subRule.Anchors is null || subRule.Anchors.Count == 0)
126+
continue;
127+
128+
if (subRule.Anchors.TryGetValue("!", out _))
129+
return FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, subRule.To, null, fetchedCrossLinks, out resolvedUri);
130+
if (subRule.Anchors.TryGetValue(originalFragment, out var mappedAnchor))
131+
return FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, subRule.To, mappedAnchor, fetchedCrossLinks, out resolvedUri);
132+
}
137133
}
138134

139-
if (repositoryLinks.Links.TryGetValue(lookupPath, out link))
135+
string? finalTargetFragment = null;
136+
137+
if (!string.IsNullOrEmpty(originalFragment))
140138
{
141-
lookupFragment = crossLinkUri.Fragment;
142-
return true;
139+
if (redirectRule.Anchors?.TryGetValue("!", out _) ?? false)
140+
finalTargetFragment = null;
141+
else if (redirectRule.Anchors?.TryGetValue(originalFragment, out var mappedAnchor) ?? false)
142+
finalTargetFragment = mappedAnchor;
143+
else if (redirectRule.Anchors is null || redirectRule.Anchors.Count == 0)
144+
finalTargetFragment = originalFragment;
145+
else
146+
{
147+
errorEmitter($"Redirect rule for '{originalLookupPath}' in '{originalCrossLinkUri.Scheme}' found, but top-level rule did not handle anchor '#{originalFragment}'.");
148+
return false;
149+
}
143150
}
144151

145-
var linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{crossLinkUri.Scheme}/main/links.json";
146-
if (crossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var linkIndexEntry))
147-
linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/{linkIndexEntry.Path}";
148-
149-
errorEmitter($"'{lookupPath}' is not a valid link in the '{crossLinkUri.Scheme}' cross link index: {linksJson}");
150-
return false;
152+
return string.IsNullOrEmpty(redirectRule.To)
153+
? FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, originalLookupPath, finalTargetFragment, fetchedCrossLinks, out resolvedUri)
154+
: FinalizeRedirect(errorEmitter, uriResolver, originalCrossLinkUri, redirectRule.To, finalTargetFragment, fetchedCrossLinks, out resolvedUri);
151155
}
152156

153-
private static bool ResolveLinkRedirect(
154-
LinkSingleRedirect[] redirects,
157+
private static bool FinalizeRedirect(
155158
Action<string> errorEmitter,
156-
RepositoryLinks repositoryLinks,
157-
Uri crossLinkUri,
158-
ref string lookupPath, out LinkMetadata? link, ref string? lookupFragment)
159+
IUriEnvironmentResolver uriResolver,
160+
Uri originalProcessingUri,
161+
string redirectToPath,
162+
string? targetFragment,
163+
FetchedCrossLinks fetchedCrossLinks,
164+
[NotNullWhen(true)] out Uri? resolvedUri)
159165
{
160-
var fragment = crossLinkUri.Fragment.TrimStart('#');
161-
link = null;
162-
foreach (var redirect in redirects)
166+
resolvedUri = null;
167+
string finalPathForResolver;
168+
169+
if (Uri.TryCreate(redirectToPath, UriKind.Absolute, out var targetCrossUri) && targetCrossUri.Scheme != "http" && targetCrossUri.Scheme != "https")
163170
{
164-
if (string.IsNullOrEmpty(redirect.To))
165-
continue;
166-
if (!repositoryLinks.Links.TryGetValue(redirect.To, out link))
167-
continue;
171+
var lookupPath = $"{targetCrossUri.Host}/{targetCrossUri.AbsolutePath.TrimStart('/')}";
172+
finalPathForResolver = ToTargetUrlPath(lookupPath);
168173

169-
if (string.IsNullOrEmpty(fragment))
170-
{
171-
lookupPath = redirect.To;
172-
return true;
173-
}
174+
if (!string.IsNullOrEmpty(targetFragment) && targetFragment != "!")
175+
finalPathForResolver += $"#{targetFragment}";
174176

175-
if (redirect.Anchors is null || redirect.Anchors.Count == 0)
177+
if (!fetchedCrossLinks.LinkReferences.TryGetValue(targetCrossUri.Scheme, out var targetLinkReference))
176178
{
177-
if (redirects.Length > 1)
178-
continue;
179-
lookupPath = redirect.To;
180-
lookupFragment = crossLinkUri.Fragment;
181-
return true;
179+
errorEmitter($"Redirect target '{redirectToPath}' points to repository '{targetCrossUri.Scheme}' for which no links.json was found.");
180+
return false;
182181
}
183182

184-
if (redirect.Anchors.TryGetValue("!", out _))
183+
if (!targetLinkReference.Links.ContainsKey(lookupPath))
185184
{
186-
lookupPath = redirect.To;
187-
lookupFragment = null;
188-
return true;
185+
errorEmitter($"Redirect target '{redirectToPath}' points to file '{lookupPath}' which was not found in repository '{targetCrossUri.Scheme}'s links.json.");
186+
return false;
189187
}
190188

191-
if (!redirect.Anchors.TryGetValue(crossLinkUri.Fragment.TrimStart('#'), out var newFragment))
192-
continue;
193-
194-
lookupPath = redirect.To;
195-
lookupFragment = newFragment;
196-
return true;
189+
resolvedUri = uriResolver.Resolve(targetCrossUri, finalPathForResolver); // Use targetUri for scheme and base
197190
}
191+
else
192+
{
193+
finalPathForResolver = ToTargetUrlPath(redirectToPath);
194+
if (!string.IsNullOrEmpty(targetFragment) && targetFragment != "!")
195+
finalPathForResolver += $"#{targetFragment}";
198196

199-
var targets = string.Join(", ", redirects.Select(r => r.To));
200-
var failedLookup = lookupFragment is null ? lookupPath : $"{lookupPath}#{lookupFragment.TrimStart('#')}";
201-
errorEmitter($"'{failedLookup}' is set a redirect but none of redirect '{targets}' match or exist in links.json.");
202-
return false;
197+
resolvedUri = uriResolver.Resolve(originalProcessingUri, finalPathForResolver); // Use original URI's scheme
198+
}
199+
return true;
203200
}
204201

205202
private static string ToTargetUrlPath(string lookupPath)

src/Elastic.Markdown/Links/InboundLinks/LinkIndexLinkChecker.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ RepositoryFilter filter
126126
}
127127

128128
collector.EmitError(repository, s);
129-
}, s => collector.EmitWarning(linksJson, s), uri, out _);
129+
}, uri, out _);
130130
}
131131
}
132132
// non-strict for now

src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,6 @@ private static void ProcessCrossLink(LinkInline link, InlineProcessor processor,
184184

185185
if (context.CrossLinkResolver.TryResolve(
186186
s => processor.EmitError(link, s),
187-
s => processor.EmitWarning(link, s),
188187
uri, out var resolvedUri)
189188
)
190189
link.Url = resolvedUri.ToString();

0 commit comments

Comments
 (0)