Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
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
5 changes: 4 additions & 1 deletion docs/_docset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ toc:
- file: req.md
- folder: nested
- file: cross-links.md
children:
- title: "Getting Started Guide"
crosslink: docs-content://get-started/introduction.md
- file: custom-highlighters.md
- hidden: archive.md
- hidden: landing-page.md
Expand All @@ -153,4 +156,4 @@ toc:
- file: bar.md
- folder: baz
children:
- file: qux.md
- file: qux.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 @@ -31,9 +31,9 @@ public static AssemblyConfiguration Deserialize(string yaml, bool skipPrivateRep
config.ReferenceRepositories[name] = repository;
}

// if we are not running in CI, and we are skipping private repositories, and we can locate the solution directory. build the local docs-content repository
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CI"))
&& skipPrivateRepositories
// If we are skipping private repositories, and we can locate the solution directory. include the local docs-content repository
// this allows us to test new docset features as part of the assembler build
if (skipPrivateRepositories
&& config.ReferenceRepositories.TryGetValue("docs-builder", out var docsContentRepository)
&& Paths.GetSolutionDirectory() is { } solutionDir
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Runtime.InteropServices;
using Elastic.Documentation.Configuration.Plugins.DetectionRules.TableOfContents;
using Elastic.Documentation.Configuration.TableOfContents;
using Elastic.Documentation.Links;
using Elastic.Documentation.Navigation;
using YamlDotNet.RepresentationModel;

Expand Down Expand Up @@ -129,6 +130,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 +151,19 @@ 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);
// Validate crosslink URI early
if (!CrossLinkValidator.IsValidCrossLink(crossLink, out var errorMessage))
{
reader.EmitError(errorMessage!, tocEntry);
crossLink = null; // Reset to prevent further processing
}
break;
case "folder":
folder = ReadFolder(reader, entry, parentPath);
parentPath += $"{Path.DirectorySeparatorChar}{folder}";
Expand All @@ -165,6 +181,22 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
}
}

// Validate that crosslink entries have titles
if (crossLink is not null && string.IsNullOrWhiteSpace(title))
{
reader.EmitError($"Cross-link entries must have a 'title' specified. Cross-link: {crossLink}", tocEntry);
return null;
}

// Validate that standalone titles (without content) are not allowed
if (!string.IsNullOrWhiteSpace(title) &&
file is null && crossLink is null && folder is null && toc is null &&
(detectionRules is null || detectionRules.Length == 0))
{
reader.EmitError($"Table of contents entries with only a 'title' are not allowed. Entry must specify content (file, crosslink, folder, or toc). Title: '{title}'", tocEntry);
return null;
}

if (toc is not null)
{
foreach (var f in toc.Files)
Expand Down Expand Up @@ -199,6 +231,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
69 changes: 69 additions & 0 deletions src/Elastic.Documentation/Links/CrossLinkValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// 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.Collections.Immutable;

namespace Elastic.Documentation.Links;

/// <summary>
/// Utility class for validating and identifying cross-repository links
/// </summary>
public static class CrossLinkValidator
{
/// <summary>
/// URI schemes that are excluded from being treated as cross-repository links.
/// These are standard web/protocol schemes that should not be processed as crosslinks.
/// </summary>
private static readonly ImmutableHashSet<string> ExcludedSchemes =
ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase,
"http", "https", "ftp", "file", "tel", "jdbc", "mailto");

/// <summary>
/// Validates that a URI string is a valid cross-repository link.
/// </summary>
/// <param name="uriString">The URI string to validate</param>
/// <param name="errorMessage">Error message if validation fails</param>
/// <returns>True if valid crosslink, false otherwise</returns>
public static bool IsValidCrossLink(string? uriString, out string? errorMessage)
{
errorMessage = null;

if (string.IsNullOrWhiteSpace(uriString))
{
errorMessage = "Cross-link entries must specify a non-empty URI";
return false;
}

if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
{
errorMessage = $"Cross-link URI '{uriString}' is not a valid absolute URI format";
return false;
}

if (ExcludedSchemes.Contains(uri.Scheme))
{
errorMessage = $"Cross-link URI '{uriString}' cannot use standard web/protocol schemes ({string.Join(", ", ExcludedSchemes)}). Use cross-repository schemes like 'docs-content://', 'kibana://', etc.";
return false;
}

return true;
}

/// <summary>
/// Determines if a URI is a cross-repository link (for identification purposes).
/// This is more permissive than validation and is used by the Markdown parser.
/// </summary>
/// <param name="uri">The URI to check</param>
/// <returns>True if this should be treated as a crosslink</returns>
public static bool IsCrossLink(Uri? uri) =>
uri != null
&& !ExcludedSchemes.Contains(uri.Scheme)
&& !uri.IsFile
&& !string.IsNullOrEmpty(uri.Scheme);

/// <summary>
/// Gets the list of excluded URI schemes for reference
/// </summary>
public static IReadOnlySet<string> GetExcludedSchemes() => ExcludedSchemes;
}
2 changes: 1 addition & 1 deletion src/Elastic.Markdown/DocumentationGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,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
39 changes: 39 additions & 0 deletions src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// 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;
Parent = group;
NavigationRoot = group.NavigationRoot;
Hidden = hidden;
}

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
}
Loading
Loading