Skip to content

Commit bf5747a

Browse files
committed
Add crosslinks to toc
1 parent 648393c commit bf5747a

File tree

10 files changed

+274
-6
lines changed

10 files changed

+274
-6
lines changed

docs/_docset.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,5 @@ toc:
149149
- folder: baz
150150
children:
151151
- file: qux.md
152+
- title: "Getting Started Guide"
153+
crosslink: docs-content://get-started/introduction.md

src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
129129
private IEnumerable<ITocItem>? ReadChild(YamlStreamReader reader, YamlMappingNode tocEntry, string parentPath)
130130
{
131131
string? file = null;
132+
string? crossLink = null;
133+
string? title = null;
132134
string? folder = null;
133135
string[]? detectionRules = null;
134136
TableOfContentsConfiguration? toc = null;
@@ -148,6 +150,13 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
148150
hiddenFile = key == "hidden";
149151
file = ReadFile(reader, entry, parentPath);
150152
break;
153+
case "title":
154+
title = reader.ReadString(entry);
155+
break;
156+
case "crosslink":
157+
hiddenFile = false;
158+
crossLink = reader.ReadString(entry);
159+
break;
151160
case "folder":
152161
folder = ReadFolder(reader, entry, parentPath);
153162
parentPath += $"{Path.DirectorySeparatorChar}{folder}";
@@ -199,6 +208,12 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
199208
return [new FileReference(this, path, hiddenFile, children ?? [])];
200209
}
201210

211+
if (crossLink is not null)
212+
{
213+
// No validation here - we'll validate cross-links separately
214+
return [new CrossLinkReference(this, crossLink, title, hiddenFile, children ?? [])];
215+
}
216+
202217
if (folder is not null)
203218
{
204219
if (children is null)

src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ public interface ITocItem
1414
public record FileReference(ITableOfContentsScope TableOfContentsScope, string RelativePath, bool Hidden, IReadOnlyCollection<ITocItem> Children)
1515
: ITocItem;
1616

17+
public record CrossLinkReference(ITableOfContentsScope TableOfContentsScope, string CrossLinkUri, string? Title, bool Hidden, IReadOnlyCollection<ITocItem> Children)
18+
: ITocItem;
19+
1720
public record FolderReference(ITableOfContentsScope TableOfContentsScope, string RelativePath, IReadOnlyCollection<ITocItem> Children)
1821
: ITocItem;
1922

src/Elastic.Markdown/IO/DocumentationSet.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,25 @@ public DocumentationSet(
209209
.ToDictionary(kv => kv.Item1, kv => kv.Item2)
210210
.ToFrozenDictionary();
211211

212+
// Validate cross-repo links in navigation
213+
214+
try
215+
{
216+
// First ensure links are fetched - this is essential for resolving links properly
217+
_ = LinkResolver.FetchLinks(new Cancel()).GetAwaiter().GetResult();
218+
219+
NavigationCrossLinkValidator.ValidateNavigationCrossLinksAsync(
220+
Tree,
221+
LinkResolver,
222+
(msg) => Context.EmitError(Context.ConfigurationPath, msg)
223+
).GetAwaiter().GetResult();
224+
}
225+
catch (Exception e)
226+
{
227+
// Log the error but don't fail the build
228+
Context.EmitError(Context.ConfigurationPath, $"Error validating cross-links in navigation: {e.Message}");
229+
}
230+
212231
ValidateRedirectsExists();
213232
}
214233

@@ -222,6 +241,10 @@ private void UpdateNavigationIndex(IReadOnlyCollection<INavigationItem> navigati
222241
var fileIndex = Interlocked.Increment(ref navigationIndex);
223242
fileNavigationItem.NavigationIndex = fileIndex;
224243
break;
244+
case CrossLinkNavigationItem crossLinkNavigationItem:
245+
var crossLinkIndex = Interlocked.Increment(ref navigationIndex);
246+
crossLinkNavigationItem.NavigationIndex = crossLinkIndex;
247+
break;
225248
case DocumentationGroup documentationGroup:
226249
var groupIndex = Interlocked.Increment(ref navigationIndex);
227250
documentationGroup.NavigationIndex = groupIndex;
@@ -241,6 +264,9 @@ private static IReadOnlyCollection<INavigationItem> CreateNavigationLookup(INavi
241264
if (item is ILeafNavigationItem<INavigationModel> leaf)
242265
return [leaf];
243266

267+
if (item is CrossLinkNavigationItem crossLink)
268+
return [crossLink];
269+
244270
if (item is INodeNavigationItem<INavigationModel, INavigationItem> node)
245271
{
246272
var items = node.NavigationItems.SelectMany(CreateNavigationLookup);
@@ -254,6 +280,8 @@ public static (string, INavigationItem)[] Pairs(INavigationItem item)
254280
{
255281
if (item is FileNavigationItem f)
256282
return [(f.Model.CrossLink, item)];
283+
if (item is CrossLinkNavigationItem cl)
284+
return [(cl.Url, item)]; // Use the URL as the key for cross-links
257285
if (item is DocumentationGroup g)
258286
{
259287
var index = new List<(string, INavigationItem)>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Diagnostics;
6+
using System.Diagnostics.CodeAnalysis;
7+
using Elastic.Documentation.Site.Navigation;
8+
9+
namespace Elastic.Markdown.IO.Navigation;
10+
11+
[DebuggerDisplay("CrossLink: {Url}")]
12+
public record CrossLinkNavigationItem : ILeafNavigationItem<INavigationModel>
13+
{
14+
// Override Url accessor to use ResolvedUrl if available
15+
string INavigationItem.Url => ResolvedUrl ?? Url;
16+
public CrossLinkNavigationItem(string url, string? title, DocumentationGroup group, bool hidden = false)
17+
{
18+
_url = url;
19+
NavigationTitle = title ?? GetNavigationTitleFromUrl(url);
20+
Parent = group;
21+
NavigationRoot = group.NavigationRoot;
22+
Hidden = hidden;
23+
}
24+
25+
private string GetNavigationTitleFromUrl(string url)
26+
{
27+
// Extract a decent title from the URL
28+
try
29+
{
30+
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
31+
{
32+
// Get the last segment of the path and remove extension
33+
var lastSegment = uri.AbsolutePath.Split('/').Last();
34+
lastSegment = Path.GetFileNameWithoutExtension(lastSegment);
35+
36+
// Convert to title case (simple version)
37+
if (!string.IsNullOrEmpty(lastSegment))
38+
{
39+
var words = lastSegment.Replace('-', ' ').Replace('_', ' ').Split(' ');
40+
var titleCase = string.Join(" ", words.Select(w =>
41+
string.IsNullOrEmpty(w) ? "" : char.ToUpper(w[0]) + w[1..].ToLowerInvariant()));
42+
return titleCase;
43+
}
44+
}
45+
}
46+
catch
47+
{
48+
// Fall back to URL if parsing fails
49+
}
50+
51+
return url;
52+
}
53+
54+
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }
55+
public IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot { get; }
56+
// Original URL from the cross-link
57+
private readonly string _url;
58+
59+
// Store resolved URL for rendering
60+
public string? ResolvedUrl { get; set; }
61+
62+
// Implement the INavigationItem.Url property to use ResolvedUrl if available
63+
public string Url => ResolvedUrl ?? _url; public string NavigationTitle { get; }
64+
public int NavigationIndex { get; set; }
65+
public bool Hidden { get; }
66+
public INavigationModel Model => null!; // Cross-link has no local model
67+
}

src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,13 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex)
119119

120120
foreach (var tocItem in lookups.TableOfContents)
121121
{
122-
if (tocItem is FileReference file)
122+
if (tocItem is CrossLinkReference crossLink)
123+
{
124+
// Create a special navigation item for cross-repository links
125+
var crossLinkItem = new CrossLinkNavigationItem(crossLink.CrossLinkUri, crossLink.Title, this, crossLink.Hidden);
126+
AddToNavigationItems(crossLinkItem, ref fileIndex);
127+
}
128+
else if (tocItem is FileReference file)
123129
{
124130
if (!lookups.FlatMappedFiles.TryGetValue(file.RelativePath, out var d))
125131
{
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Threading.Tasks;
8+
using Elastic.Documentation.Site.Navigation;
9+
using Elastic.Markdown.Links.CrossLinks;
10+
11+
namespace Elastic.Markdown.IO.Navigation;
12+
13+
public static class NavigationCrossLinkValidator
14+
{
15+
public static async Task ValidateNavigationCrossLinksAsync(
16+
INavigationItem root,
17+
ICrossLinkResolver crossLinkResolver,
18+
Action<string> errorEmitter)
19+
{
20+
// Ensure cross-links are fetched before validation
21+
_ = await crossLinkResolver.FetchLinks(new Cancel());
22+
23+
// Collect all navigation items that contain cross-repo links
24+
var itemsWithCrossLinks = FindNavigationItemsWithCrossLinks(root);
25+
26+
foreach (var item in itemsWithCrossLinks)
27+
{
28+
if (item is CrossLinkNavigationItem crossLinkItem)
29+
{
30+
var url = crossLinkItem.Url;
31+
if (url != null && Uri.TryCreate(url, UriKind.Absolute, out var crossUri) &&
32+
crossUri.Scheme != "http" && crossUri.Scheme != "https")
33+
{
34+
// Try to resolve the cross-link URL
35+
if (crossLinkResolver.TryResolve(errorEmitter, crossUri, out var resolvedUri))
36+
{
37+
// If resolved successfully, set the resolved URL
38+
crossLinkItem.ResolvedUrl = resolvedUri.ToString();
39+
}
40+
else
41+
{
42+
// Error already emitted by CrossLinkResolver
43+
// But we won't fail the build - just display the original URL
44+
}
45+
}
46+
}
47+
else if (item is FileNavigationItem fileItem &&
48+
fileItem.Url != null &&
49+
Uri.TryCreate(fileItem.Url, UriKind.Absolute, out var fileUri) &&
50+
fileUri.Scheme != "http" &&
51+
fileUri.Scheme != "https")
52+
{
53+
// Cross-link URL detected in a FileNavigationItem, but we're not validating it yet
54+
}
55+
}
56+
57+
return;
58+
}
59+
60+
private static List<INavigationItem> FindNavigationItemsWithCrossLinks(INavigationItem item)
61+
{
62+
var results = new List<INavigationItem>();
63+
64+
// Check if this item has a cross-link
65+
if (item is CrossLinkNavigationItem crossLinkItem)
66+
{
67+
var url = crossLinkItem.Url;
68+
if (url != null &&
69+
Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
70+
uri.Scheme != "http" &&
71+
uri.Scheme != "https")
72+
{
73+
results.Add(item);
74+
}
75+
}
76+
else if (item is FileNavigationItem fileItem &&
77+
fileItem.Url != null &&
78+
Uri.TryCreate(fileItem.Url, UriKind.Absolute, out var fileUri) &&
79+
fileUri.Scheme != "http" &&
80+
fileUri.Scheme != "https")
81+
{
82+
results.Add(item);
83+
} // Recursively check children if this is a container
84+
if (item is INodeNavigationItem<INavigationModel, INavigationItem> containerItem)
85+
{
86+
foreach (var child in containerItem.NavigationItems)
87+
{
88+
results.AddRange(FindNavigationItemsWithCrossLinks(child));
89+
}
90+
}
91+
92+
return results;
93+
}
94+
}

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

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See the LICENSE file in the project root for more information
44

55
using System.Collections.Frozen;
6+
using Elastic.Documentation;
67
using Elastic.Documentation.Configuration.Builder;
78
using Elastic.Documentation.LinkIndex;
89
using Elastic.Documentation.Links;
@@ -12,18 +13,48 @@ namespace Elastic.Markdown.Links.CrossLinks;
1213

1314
public class ConfigurationCrossLinkFetcher(ILoggerFactory logFactory, ConfigurationFile configuration, ILinkIndexReader linkIndexProvider) : CrossLinkFetcher(logFactory, linkIndexProvider)
1415
{
16+
private readonly ILogger _logger = logFactory.CreateLogger(nameof(ConfigurationCrossLinkFetcher));
17+
1518
public override async Task<FetchedCrossLinks> Fetch(Cancel ctx)
1619
{
1720
var linkReferences = new Dictionary<string, RepositoryLinks>();
1821
var linkIndexEntries = new Dictionary<string, LinkRegistryEntry>();
1922
var declaredRepositories = new HashSet<string>();
23+
2024
foreach (var repository in configuration.CrossLinkRepositories)
2125
{
2226
_ = declaredRepositories.Add(repository);
23-
var linkReference = await Fetch(repository, ["main", "master"], ctx);
24-
linkReferences.Add(repository, linkReference);
25-
var linkIndexReference = await GetLinkIndexEntry(repository, ctx);
26-
linkIndexEntries.Add(repository, linkIndexReference);
27+
try
28+
{
29+
var linkReference = await Fetch(repository, ["main", "master"], ctx);
30+
linkReferences.Add(repository, linkReference);
31+
32+
var linkIndexReference = await GetLinkIndexEntry(repository, ctx);
33+
linkIndexEntries.Add(repository, linkIndexReference);
34+
}
35+
catch (Exception ex)
36+
{
37+
// Log the error but continue processing other repositories
38+
_logger.LogWarning(ex, "Error fetching link data for repository '{Repository}'. Cross-links to this repository may not resolve correctly.", repository);
39+
40+
// Add an empty entry so we at least recognize the repository exists
41+
if (!linkReferences.ContainsKey(repository))
42+
{
43+
linkReferences.Add(repository, new RepositoryLinks
44+
{
45+
Links = [],
46+
Origin = new GitCheckoutInformation
47+
{
48+
Branch = "main",
49+
RepositoryName = repository,
50+
Remote = "origin",
51+
Ref = "refs/heads/main"
52+
},
53+
UrlPathPrefix = "",
54+
CrossLinks = []
55+
});
56+
}
57+
}
2758
}
2859

2960
return new FetchedCrossLinks

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,21 @@ public static bool TryResolve(
5050
{
5151
resolvedUri = null;
5252

53+
// First check if the repository is in the declared repositories list, even if it's not in the link references
54+
var isDeclaredRepo = fetchedCrossLinks.DeclaredRepositories.Contains(crossLinkUri.Scheme);
55+
5356
if (!fetchedCrossLinks.LinkReferences.TryGetValue(crossLinkUri.Scheme, out var sourceLinkReference))
5457
{
58+
// If it's a declared repository, we might be in a development environment or failed to fetch it,
59+
// so let's generate a synthesized URL to avoid blocking development
60+
if (isDeclaredRepo)
61+
{
62+
// Create a synthesized URL for development purposes
63+
var path = ToTargetUrlPath((crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/'));
64+
resolvedUri = uriResolver.Resolve(crossLinkUri, path);
65+
return true;
66+
}
67+
5568
errorEmitter($"'{crossLinkUri.Scheme}' was not found in the cross link index");
5669
return false;
5770
}
@@ -66,6 +79,15 @@ public static bool TryResolve(
6679
if (sourceLinkReference.Links.TryGetValue(originalLookupPath, out var directLinkMetadata))
6780
return ResolveDirectLink(errorEmitter, uriResolver, crossLinkUri, originalLookupPath, directLinkMetadata, out resolvedUri);
6881

82+
// For development docs or known repositories, allow links even if they don't exist in the link index
83+
if (isDeclaredRepo)
84+
{
85+
// Create a synthesized URL for development purposes
86+
var path = ToTargetUrlPath(originalLookupPath);
87+
resolvedUri = uriResolver.Resolve(crossLinkUri, path);
88+
return true;
89+
}
90+
6991

7092
var linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{crossLinkUri.Scheme}/main/links.json";
7193
if (fetchedCrossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var indexEntry))
@@ -199,7 +221,7 @@ private static bool FinalizeRedirect(
199221
return true;
200222
}
201223

202-
private static string ToTargetUrlPath(string lookupPath)
224+
public static string ToTargetUrlPath(string lookupPath)
203225
{
204226
//https://docs-v3-preview.elastic.dev/elastic/docs-content/tree/main/cloud-account/change-your-password
205227
var path = lookupPath.Replace(".md", "");

updatecli/updatecli.d/versions.yml

Whitespace-only changes.

0 commit comments

Comments
 (0)