Skip to content

Add crosslinks to toc: in docset.yml #1615

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 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bf5747a
Add crosslinks to toc
theletterf Jul 25, 2025
c1bb57d
Fix errors
theletterf Jul 25, 2025
c92fb38
Update docs
theletterf Jul 25, 2025
d4bd6ca
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Jul 28, 2025
b78c2c3
Add title validation
theletterf Jul 28, 2025
04920ab
Add ctx for Cancel
theletterf Jul 28, 2025
effe888
FileNavigationItem can be ignored
theletterf Jul 28, 2025
03ec234
Remove redundant code
theletterf Jul 28, 2025
d3a8574
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Jul 28, 2025
134b86d
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Jul 29, 2025
081d4c4
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Aug 5, 2025
ca8bc40
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Aug 19, 2025
db1db5e
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Aug 20, 2025
2567bf4
Move routine
theletterf Aug 20, 2025
cb1d64a
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Aug 20, 2025
a19d49b
Fix resolution
theletterf Aug 20, 2025
4f4ebbe
Merge branch 'crosslinks-in-toc-take-three' of github.com:elastic/doc…
theletterf Aug 20, 2025
30ed149
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Aug 20, 2025
37a224f
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Aug 20, 2025
ac0284b
Fix hx-select-oob for nav crosslinks
theletterf Aug 21, 2025
f52e66e
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Aug 21, 2025
b0e6ec3
Merge branch 'main' into crosslinks-in-toc-take-three
Mpdreamz Aug 21, 2025
0a52f75
Add validation and title as mandatory
theletterf Aug 22, 2025
05c8f8c
Add utility class for crosslink validation
theletterf Aug 22, 2025
f51258d
Remove redundant file
theletterf Aug 22, 2025
7d3d364
Refactor NavCrossLinkValidator
theletterf Aug 22, 2025
e0ea3bb
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Aug 22, 2025
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
4 changes: 4 additions & 0 deletions docs/_docset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,7 @@ toc:
- folder: baz
children:
- file: qux.md
- title: "Getting Started Guide"
crosslink: docs-content://get-started/introduction.md
- title: "Test title"
crosslink: docs-content://solutions/search/elasticsearch-basics-quickstart.md
28 changes: 27 additions & 1 deletion docs/configure/content-set/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,38 @@ cross_links:
- docs-content
```

#### Adding cross-links in Markdown content

To link to a document in the `docs-content` repository, you would write the link as follows:

```
```markdown
[Link to docs-content doc](docs-content://directory/another-directory/file.md)
```

You can also link to specific anchors within the document:

```markdown
[Link to specific section](docs-content://directory/file.md#section-id)
```

#### Adding cross-links in navigation

Cross-links can also be included in navigation structures. When creating a `toc.yml` file or defining navigation in `docset.yml`, you can add cross-links as follows:

```yaml
toc:
- file: index.md
- title: External Documentation
crosslink: docs-content://directory/file.md
- folder: local-section
children:
- file: index.md
- title: API Reference
crosslink: elasticsearch://api/index.html
```

Cross-links in navigation will be automatically resolved during the build process, maintaining consistent linking between related documentation across repositories.

### `exclude`

Files to exclude from the TOC. Supports glob patterns.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
private IEnumerable<ITocItem>? ReadChild(YamlStreamReader reader, YamlMappingNode tocEntry, string parentPath)
{
string? file = null;
string? crossLink = null;
string? title = null;
string? folder = null;
string[]? detectionRules = null;
TableOfContentsConfiguration? toc = null;
Expand All @@ -148,6 +150,13 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
hiddenFile = key == "hidden";
file = ReadFile(reader, entry, parentPath);
break;
case "title":
title = reader.ReadString(entry);
break;
case "crosslink":
hiddenFile = false;
crossLink = reader.ReadString(entry);
break;
case "folder":
folder = ReadFolder(reader, entry, parentPath);
parentPath += $"{Path.DirectorySeparatorChar}{folder}";
Expand Down Expand Up @@ -199,6 +208,12 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
return [new FileReference(this, path, hiddenFile, children ?? [])];
}

if (crossLink is not null)
{
// No validation here - we'll validate cross-links separately
return [new CrossLinkReference(this, crossLink, title, hiddenFile, children ?? [])];
}

if (folder is not null)
{
if (children is null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public interface ITocItem
public record FileReference(ITableOfContentsScope TableOfContentsScope, string RelativePath, bool Hidden, IReadOnlyCollection<ITocItem> Children)
: ITocItem;

public record CrossLinkReference(ITableOfContentsScope TableOfContentsScope, string CrossLinkUri, string? Title, bool Hidden, IReadOnlyCollection<ITocItem> Children)
: ITocItem;

public record FolderReference(ITableOfContentsScope TableOfContentsScope, string RelativePath, IReadOnlyCollection<ITocItem> Children)
: ITocItem;

Expand Down
28 changes: 28 additions & 0 deletions src/Elastic.Markdown/IO/DocumentationSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,25 @@ public DocumentationSet(
.ToDictionary(kv => kv.Item1, kv => kv.Item2)
.ToFrozenDictionary();

// Validate cross-repo links in navigation

try
{
// First ensure links are fetched - this is essential for resolving links properly
_ = LinkResolver.FetchLinks(new Cancel()).GetAwaiter().GetResult();

NavigationCrossLinkValidator.ValidateNavigationCrossLinksAsync(
Tree,
LinkResolver,
(msg) => Context.EmitError(Context.ConfigurationPath, msg)
).GetAwaiter().GetResult();
}
catch (Exception e)
{
// Log the error but don't fail the build
Context.EmitError(Context.ConfigurationPath, $"Error validating cross-links in navigation: {e.Message}");
}

ValidateRedirectsExists();
}

Expand All @@ -222,6 +241,10 @@ private void UpdateNavigationIndex(IReadOnlyCollection<INavigationItem> navigati
var fileIndex = Interlocked.Increment(ref navigationIndex);
fileNavigationItem.NavigationIndex = fileIndex;
break;
case CrossLinkNavigationItem crossLinkNavigationItem:
var crossLinkIndex = Interlocked.Increment(ref navigationIndex);
crossLinkNavigationItem.NavigationIndex = crossLinkIndex;
break;
case DocumentationGroup documentationGroup:
var groupIndex = Interlocked.Increment(ref navigationIndex);
documentationGroup.NavigationIndex = groupIndex;
Expand All @@ -241,6 +264,9 @@ private static IReadOnlyCollection<INavigationItem> CreateNavigationLookup(INavi
if (item is ILeafNavigationItem<INavigationModel> leaf)
return [leaf];

if (item is CrossLinkNavigationItem crossLink)
return [crossLink];

if (item is INodeNavigationItem<INavigationModel, INavigationItem> node)
{
var items = node.NavigationItems.SelectMany(CreateNavigationLookup);
Expand All @@ -254,6 +280,8 @@ public static (string, INavigationItem)[] Pairs(INavigationItem item)
{
if (item is FileNavigationItem f)
return [(f.Model.CrossLink, item)];
if (item is CrossLinkNavigationItem cl)
return [(cl.Url, item)]; // Use the URL as the key for cross-links
if (item is DocumentationGroup g)
{
var index = new List<(string, INavigationItem)>
Expand Down
67 changes: 67 additions & 0 deletions src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Elastic.Documentation.Site.Navigation;

namespace Elastic.Markdown.IO.Navigation;

[DebuggerDisplay("CrossLink: {Url}")]
public record CrossLinkNavigationItem : ILeafNavigationItem<INavigationModel>
{
// Override Url accessor to use ResolvedUrl if available
string INavigationItem.Url => ResolvedUrl ?? Url;
public CrossLinkNavigationItem(string url, string? title, DocumentationGroup group, bool hidden = false)
{
_url = url;
NavigationTitle = title ?? GetNavigationTitleFromUrl(url);
Parent = group;
NavigationRoot = group.NavigationRoot;
Hidden = hidden;
}

private string GetNavigationTitleFromUrl(string url)
{
// Extract a decent title from the URL
try
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
// Get the last segment of the path and remove extension
var lastSegment = uri.AbsolutePath.Split('/').Last();
lastSegment = Path.GetFileNameWithoutExtension(lastSegment);

// Convert to title case (simple version)
if (!string.IsNullOrEmpty(lastSegment))
{
var words = lastSegment.Replace('-', ' ').Replace('_', ' ').Split(' ');
var titleCase = string.Join(" ", words.Select(w =>
string.IsNullOrEmpty(w) ? "" : char.ToUpper(w[0]) + w[1..].ToLowerInvariant()));
return titleCase;
}
}
}
catch
{
// Fall back to URL if parsing fails
}

return url;
}

public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }
public IRootNavigationItem<INavigationModel, INavigationItem> NavigationRoot { get; }
// Original URL from the cross-link
private readonly string _url;

// Store resolved URL for rendering
public string? ResolvedUrl { get; set; }

// Implement the INavigationItem.Url property to use ResolvedUrl if available
public string Url => ResolvedUrl ?? _url; public string NavigationTitle { get; }
public int NavigationIndex { get; set; }
public bool Hidden { get; }
public INavigationModel Model => null!; // Cross-link has no local model
}
8 changes: 7 additions & 1 deletion src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,13 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex)

foreach (var tocItem in lookups.TableOfContents)
{
if (tocItem is FileReference file)
if (tocItem is CrossLinkReference crossLink)
{
// Create a special navigation item for cross-repository links
var crossLinkItem = new CrossLinkNavigationItem(crossLink.CrossLinkUri, crossLink.Title, this, crossLink.Hidden);
AddToNavigationItems(crossLinkItem, ref fileIndex);
}
else if (tocItem is FileReference file)
{
if (!lookups.FlatMappedFiles.TryGetValue(file.RelativePath, out var d))
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// 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

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Elastic.Documentation.Site.Navigation;
using Elastic.Markdown.Links.CrossLinks;

namespace Elastic.Markdown.IO.Navigation;

public static class NavigationCrossLinkValidator
{
public static async Task ValidateNavigationCrossLinksAsync(
INavigationItem root,
ICrossLinkResolver crossLinkResolver,
Action<string> errorEmitter)
{
// Ensure cross-links are fetched before validation
_ = await crossLinkResolver.FetchLinks(new Cancel());

// Collect all navigation items that contain cross-repo links
var itemsWithCrossLinks = FindNavigationItemsWithCrossLinks(root);

foreach (var item in itemsWithCrossLinks)
{
if (item is CrossLinkNavigationItem crossLinkItem)
{
var url = crossLinkItem.Url;
if (url != null && Uri.TryCreate(url, UriKind.Absolute, out var crossUri) &&
crossUri.Scheme != "http" && crossUri.Scheme != "https")
{
// Try to resolve the cross-link URL
if (crossLinkResolver.TryResolve(errorEmitter, crossUri, out var resolvedUri))
{
// If resolved successfully, set the resolved URL
crossLinkItem.ResolvedUrl = resolvedUri.ToString();
}
else
{
// Error already emitted by CrossLinkResolver
// But we won't fail the build - just display the original URL
}
}
}
else if (item is FileNavigationItem fileItem &&
fileItem.Url != null &&
Uri.TryCreate(fileItem.Url, UriKind.Absolute, out var fileUri) &&
fileUri.Scheme != "http" &&
fileUri.Scheme != "https")
{
// Cross-link URL detected in a FileNavigationItem, but we're not validating it yet
}
}

return;
}

private static List<INavigationItem> FindNavigationItemsWithCrossLinks(INavigationItem item)
{
var results = new List<INavigationItem>();

// Check if this item has a cross-link
if (item is CrossLinkNavigationItem crossLinkItem)
{
var url = crossLinkItem.Url;
if (url != null &&
Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
uri.Scheme != "http" &&
uri.Scheme != "https")
{
results.Add(item);
}
}
else if (item is FileNavigationItem fileItem &&
fileItem.Url != null &&
Uri.TryCreate(fileItem.Url, UriKind.Absolute, out var fileUri) &&
fileUri.Scheme != "http" &&
fileUri.Scheme != "https")
{
results.Add(item);
} // Recursively check children if this is a container
if (item is INodeNavigationItem<INavigationModel, INavigationItem> containerItem)
{
foreach (var child in containerItem.NavigationItems)
{
results.AddRange(FindNavigationItemsWithCrossLinks(child));
}
}

return results;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information

using System.Collections.Frozen;
using Elastic.Documentation;
using Elastic.Documentation.Configuration.Builder;
using Elastic.Documentation.LinkIndex;
using Elastic.Documentation.Links;
Expand All @@ -12,18 +13,48 @@ namespace Elastic.Markdown.Links.CrossLinks;

public class ConfigurationCrossLinkFetcher(ILoggerFactory logFactory, ConfigurationFile configuration, ILinkIndexReader linkIndexProvider) : CrossLinkFetcher(logFactory, linkIndexProvider)
{
private readonly ILogger _logger = logFactory.CreateLogger(nameof(ConfigurationCrossLinkFetcher));

public override async Task<FetchedCrossLinks> Fetch(Cancel ctx)
{
var linkReferences = new Dictionary<string, RepositoryLinks>();
var linkIndexEntries = new Dictionary<string, LinkRegistryEntry>();
var declaredRepositories = new HashSet<string>();

foreach (var repository in configuration.CrossLinkRepositories)
{
_ = declaredRepositories.Add(repository);
var linkReference = await Fetch(repository, ["main", "master"], ctx);
linkReferences.Add(repository, linkReference);
var linkIndexReference = await GetLinkIndexEntry(repository, ctx);
linkIndexEntries.Add(repository, linkIndexReference);
try
{
var linkReference = await Fetch(repository, ["main", "master"], ctx);
linkReferences.Add(repository, linkReference);

var linkIndexReference = await GetLinkIndexEntry(repository, ctx);
linkIndexEntries.Add(repository, linkIndexReference);
}
catch (Exception ex)
{
// Log the error but continue processing other repositories
_logger.LogWarning(ex, "Error fetching link data for repository '{Repository}'. Cross-links to this repository may not resolve correctly.", repository);

// Add an empty entry so we at least recognize the repository exists
if (!linkReferences.ContainsKey(repository))
{
linkReferences.Add(repository, new RepositoryLinks
{
Links = [],
Origin = new GitCheckoutInformation
{
Branch = "main",
RepositoryName = repository,
Remote = "origin",
Ref = "refs/heads/main"
},
UrlPathPrefix = "",
CrossLinks = []
});
}
}
}

return new FetchedCrossLinks
Expand Down
Loading
Loading