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 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
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
4 changes: 4 additions & 0 deletions docs/_docset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,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,11 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
return [new FileReference(this, path, hiddenFile, children ?? [])];
}

if (crossLink is not null)
{
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
2 changes: 1 addition & 1 deletion src/Elastic.Markdown/DocumentationGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public DocumentationGenerator(
public async Task ResolveDirectoryTree(Cancel ctx)
{
_logger.LogInformation("Resolving tree");
await DocumentationSet.Tree.Resolve(ctx);
await DocumentationSet.ResolveDirectoryTree(ctx);
_logger.LogInformation("Resolved tree");
}

Expand Down
29 changes: 28 additions & 1 deletion src/Elastic.Markdown/IO/DocumentationSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,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 +245,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 +261,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 Expand Up @@ -365,9 +374,27 @@ void ValidateExists(string from, string to, IReadOnlyDictionary<string, string?>
return FlatMappedFiles.GetValueOrDefault(relativePath);
}

public async Task ResolveDirectoryTree(Cancel ctx) =>
public async Task ResolveDirectoryTree(Cancel ctx)
{
await Tree.Resolve(ctx);

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

private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext context)
{
var relativePath = Path.GetRelativePath(SourceDirectory.FullName, file.FullName);
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
}
14 changes: 13 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,19 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex)

foreach (var tocItem in lookups.TableOfContents)
{
if (tocItem is FileReference file)
if (tocItem is CrossLinkReference crossLink)
{
if (string.IsNullOrWhiteSpace(crossLink.Title))
{
context.EmitError(context.ConfigurationPath,
$"Cross-link entries must have a 'title' specified. Cross-link: {crossLink.CrossLinkUri}");
continue;
}
// 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,87 @@
// 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,
Cancel ctx = default)
{
// Ensure cross-links are fetched before validation
_ = await crossLinkResolver.FetchLinks(ctx);
// 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);
}
}
// 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