Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
34 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
10e8a52
Merge branch 'main' into crosslinks-in-toc-take-three
Mpdreamz Aug 26, 2025
05cc209
Ensure we inject docs-builder on CI for integration tests as well
Mpdreamz Aug 26, 2025
4f9ca60
allow docs-builder to have local checkout folder on CI
Mpdreamz Aug 26, 2025
cca7d09
Ensure we hadnle CrossLinkNavigationItem when building the sitemap by…
Mpdreamz Aug 26, 2025
0e0215d
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Aug 27, 2025
2a3a20a
Remove `Fetch` from CrossLinkResolver, enforce eager fetching of cros…
Mpdreamz Aug 27, 2025
3182442
Merge branch 'main' into crosslinks-in-toc-take-three
theletterf Sep 2, 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 @@ -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
3 changes: 3 additions & 0 deletions src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class LandingNavigationItem : IApiGroupingNavigationItem<ApiLanding, INav
public IReadOnlyCollection<INavigationItem> NavigationItems { get; set; } = [];
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }
public int NavigationIndex { get; set; }
public bool IsCrossLink => false; // API landing items are never cross-links
public string Url { get; }
public bool Hidden => false;

Expand Down Expand Up @@ -83,6 +84,7 @@ public abstract class ApiGroupingNavigationItem<TGroupingModel, TNavigationItem>
public bool Hidden => false;
/// <inheritdoc />
public int NavigationIndex { get; set; }
public bool IsCrossLink => false; // API grouping items are never cross-links

/// <inheritdoc />
public int Depth => 0;
Expand Down Expand Up @@ -141,6 +143,7 @@ public class EndpointNavigationItem(ApiEndpoint endpoint, IRootNavigationItem<IA

/// <inheritdoc />
public int NavigationIndex { get; set; }
public bool IsCrossLink => false; // API endpoint items are never cross-links

/// <inheritdoc />
public int Depth => 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,6 @@ IApiGroupingNavigationItem<IApiGroupingModel, INavigationItem> parent
public INodeNavigationItem<INavigationModel, INavigationItem>? Parent { get; set; }

public int NavigationIndex { get; set; }
public bool IsCrossLink => false; // API operations are never cross-links

}
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public interface INavigationItem
bool Hidden { get; }

int NavigationIndex { get; set; }

/// Gets whether this navigation item is a cross-link to another repository.
bool IsCrossLink { get; }
}

/// Represents a leaf node in the navigation tree with associated model data.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,11 @@
}
else if (item is ILeafNavigationItem<INavigationModel> leaf)
{
var hasSameTopLevelGroup = !leaf.IsCrossLink && (Model.IsPrimaryNavEnabled && leaf.NavigationRoot.Id == Model.RootNavigationId || true);
<li class="flex group/li pr-8 @(isTopLevel ? "font-semibold mt-6" : "mt-4")">
<a
href="@leaf.Url"
@Htmx.GetNavHxAttributes(Model.IsPrimaryNavEnabled && leaf.NavigationRoot.Id == Model.RootNavigationId || true)
@Htmx.GetNavHxAttributes(hasSameTopLevelGroup)
class="sidebar-link grow group-[.current]/li:text-blue-elastic!"
>
@leaf.NavigationTitle
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
68 changes: 68 additions & 0 deletions src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// 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 bool IsCrossLink => true; // This is always a cross-link
public INavigationModel Model => null!; // Cross-link has no local model
}
16 changes: 15 additions & 1 deletion src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public class DocumentationGroup : INodeNavigationItem<MarkdownFile, INavigationI

public int NavigationIndex { get; set; }

public bool IsCrossLink => false; // Documentation groups are never cross-links

private IReadOnlyCollection<MarkdownFile> FilesInOrder { get; }

private IReadOnlyCollection<DocumentationGroup> GroupsInOrder { get; }
Expand Down Expand Up @@ -119,7 +121,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
1 change: 1 addition & 0 deletions src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ public record FileNavigationItem(MarkdownFile Model, DocumentationGroup Group, b
public string Url => Model.Url;
public string NavigationTitle => Model.NavigationTitle;
public int NavigationIndex { get; set; }
public bool IsCrossLink => false; // File navigation items are never cross-links
}
Loading
Loading