Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
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
1 change: 1 addition & 0 deletions docs-builder.sln.DotSettings
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=crosslink/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=docset/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=frontmatter/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=linenos/@EntryIndexedValue">True</s:Boolean>
Expand Down
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,14 @@ private IReadOnlyCollection<ITocItem> ReadChildren(YamlStreamReader reader, KeyV
return [new FileReference(this, path, hiddenFile, children ?? [])];
}

if (crossLink is not null)
{
if (Uri.TryCreate(crossLink, UriKind.Absolute, out var crossUri) && CrossLinkValidator.IsCrossLink(crossUri))
return [new CrossLinkReference(this, crossUri, title, hiddenFile, children ?? [])];
else
reader.EmitError($"Cross-link '{crossLink}' is not a valid absolute URI format", tocEntry);
}

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, Uri 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;
}
9 changes: 3 additions & 6 deletions src/Elastic.Markdown/DocumentationGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public class DocumentationGenerator

public DocumentationSet DocumentationSet { get; }
public BuildContext Context { get; }
public ICrossLinkResolver Resolver { get; }
public ICrossLinkResolver CrossLinkResolver { get; }
public IMarkdownStringRenderer MarkdownStringRenderer => HtmlWriter;

public DocumentationGenerator(
Expand All @@ -70,7 +70,7 @@ public DocumentationGenerator(

DocumentationSet = docSet;
Context = docSet.Context;
Resolver = docSet.LinkResolver;
CrossLinkResolver = docSet.CrossLinkResolver;
HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem, new DescriptionGenerator(), navigationHtmlWriter, legacyUrlMapper,
positionalNavigation);
_documentationFileExporter =
Expand All @@ -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 All @@ -120,9 +120,6 @@ public async Task<GenerationResult> GenerateAll(Cancel ctx)
if (CompilationNotNeeded(generationState, out var offendingFiles, out var outputSeenChanges))
return result;

_logger.LogInformation($"Fetching external links");
_ = await Resolver.FetchLinks(ctx);

await ResolveDirectoryTree(ctx);

await ProcessDocumentationFiles(offendingFiles, outputSeenChanges, ctx);
Expand Down
Loading
Loading