diff --git a/docs/_docset.yml b/docs/_docset.yml index e2b78ad54..eda45cd88 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -153,3 +153,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 \ No newline at end of file diff --git a/docs/configure/content-set/navigation.md b/docs/configure/content-set/navigation.md index 318a32804..d4b7cac73 100644 --- a/docs/configure/content-set/navigation.md +++ b/docs/configure/content-set/navigation.md @@ -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. diff --git a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs index 0755113ef..63630faf3 100644 --- a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs @@ -129,6 +129,8 @@ private IReadOnlyCollection ReadChildren(YamlStreamReader reader, KeyV private IEnumerable? 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; @@ -148,6 +150,13 @@ private IReadOnlyCollection 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}"; @@ -199,6 +208,11 @@ private IReadOnlyCollection 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) diff --git a/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs b/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs index a5f745150..29ea93ca4 100644 --- a/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs +++ b/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs @@ -14,6 +14,9 @@ public interface ITocItem public record FileReference(ITableOfContentsScope TableOfContentsScope, string RelativePath, bool Hidden, IReadOnlyCollection Children) : ITocItem; +public record CrossLinkReference(ITableOfContentsScope TableOfContentsScope, string CrossLinkUri, string? Title, bool Hidden, IReadOnlyCollection Children) + : ITocItem; + public record FolderReference(ITableOfContentsScope TableOfContentsScope, string RelativePath, IReadOnlyCollection Children) : ITocItem; diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 7c8c5784d..0c6febaec 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -209,6 +209,22 @@ public DocumentationSet( .ToDictionary(kv => kv.Item1, kv => kv.Item2) .ToFrozenDictionary(); + // Validate cross-repo links in navigation + + try + { + 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(); } @@ -222,6 +238,10 @@ private void UpdateNavigationIndex(IReadOnlyCollection 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; @@ -241,6 +261,9 @@ private static IReadOnlyCollection CreateNavigationLookup(INavi if (item is ILeafNavigationItem leaf) return [leaf]; + if (item is CrossLinkNavigationItem crossLink) + return [crossLink]; + if (item is INodeNavigationItem node) { var items = node.NavigationItems.SelectMany(CreateNavigationLookup); @@ -254,6 +277,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)> diff --git a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs b/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs new file mode 100644 index 000000000..1a98d63fd --- /dev/null +++ b/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs @@ -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 +{ + // 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? Parent { get; set; } + public IRootNavigationItem 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 +} diff --git a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs index 038b857be..1532fbbb6 100644 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs @@ -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)) { diff --git a/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs b/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs new file mode 100644 index 000000000..edccb7843 --- /dev/null +++ b/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs @@ -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 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 FindNavigationItemsWithCrossLinks(INavigationItem item) + { + var results = new List(); + + // 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 containerItem) + { + foreach (var child in containerItem.NavigationItems) + { + results.AddRange(FindNavigationItemsWithCrossLinks(child)); + } + } + + return results; + } +} diff --git a/src/Elastic.Markdown/Links/CrossLinks/ConfigurationCrossLinkFetcher.cs b/src/Elastic.Markdown/Links/CrossLinks/ConfigurationCrossLinkFetcher.cs index c3a64c228..4ef1ee4b1 100644 --- a/src/Elastic.Markdown/Links/CrossLinks/ConfigurationCrossLinkFetcher.cs +++ b/src/Elastic.Markdown/Links/CrossLinks/ConfigurationCrossLinkFetcher.cs @@ -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; @@ -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 Fetch(Cancel ctx) { var linkReferences = new Dictionary(); var linkIndexEntries = new Dictionary(); var declaredRepositories = new HashSet(); + 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 diff --git a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs index c633fdbfd..72de952cb 100644 --- a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs @@ -50,8 +50,21 @@ public static bool TryResolve( { resolvedUri = null; + // First check if the repository is in the declared repositories list, even if it's not in the link references + var isDeclaredRepo = fetchedCrossLinks.DeclaredRepositories.Contains(crossLinkUri.Scheme); + if (!fetchedCrossLinks.LinkReferences.TryGetValue(crossLinkUri.Scheme, out var sourceLinkReference)) { + // If it's a declared repository, we might be in a development environment or failed to fetch it, + // so let's generate a synthesized URL to avoid blocking development + if (isDeclaredRepo) + { + // Create a synthesized URL for development purposes + var path = ToTargetUrlPath((crossLinkUri.Host + '/' + crossLinkUri.AbsolutePath.TrimStart('/')).Trim('/')); + resolvedUri = uriResolver.Resolve(crossLinkUri, path); + return true; + } + errorEmitter($"'{crossLinkUri.Scheme}' was not found in the cross link index"); return false; } @@ -66,6 +79,15 @@ public static bool TryResolve( if (sourceLinkReference.Links.TryGetValue(originalLookupPath, out var directLinkMetadata)) return ResolveDirectLink(errorEmitter, uriResolver, crossLinkUri, originalLookupPath, directLinkMetadata, out resolvedUri); + // For development docs or known repositories, allow links even if they don't exist in the link index + if (isDeclaredRepo) + { + // Create a synthesized URL for development purposes + var path = ToTargetUrlPath(originalLookupPath); + resolvedUri = uriResolver.Resolve(crossLinkUri, path); + return true; + } + var linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{crossLinkUri.Scheme}/main/links.json"; if (fetchedCrossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var indexEntry)) @@ -199,7 +221,7 @@ private static bool FinalizeRedirect( return true; } - private static string ToTargetUrlPath(string lookupPath) + public static string ToTargetUrlPath(string lookupPath) { //https://docs-v3-preview.elastic.dev/elastic/docs-content/tree/main/cloud-account/change-your-password var path = lookupPath.Replace(".md", ""); diff --git a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs index c2be8f25c..aa8f5e838 100644 --- a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs +++ b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs @@ -19,6 +19,10 @@ public class TestCrossLinkResolver : ICrossLinkResolver public Task FetchLinks(Cancel ctx) { + // Clear existing entries to prevent duplicate key errors when called multiple times + LinkReferences.Clear(); + DeclaredRepositories.Clear(); + // language=json var json = """ { @@ -47,7 +51,7 @@ public Task FetchLinks(Cancel ctx) var reference = CrossLinkFetcher.Deserialize(json); LinkReferences.Add("docs-content", reference); LinkReferences.Add("kibana", reference); - DeclaredRepositories.AddRange(["docs-content", "kibana", "elasticsearch"]); + DeclaredRepositories.AddRange(["docs-content", "kibana"]); var indexEntries = LinkReferences.ToDictionary(e => e.Key, e => new LinkRegistryEntry { diff --git a/tests/authoring/Framework/TestCrossLinkResolver.fs b/tests/authoring/Framework/TestCrossLinkResolver.fs index 26a2ac456..e9c7e4679 100644 --- a/tests/authoring/Framework/TestCrossLinkResolver.fs +++ b/tests/authoring/Framework/TestCrossLinkResolver.fs @@ -28,6 +28,10 @@ type TestCrossLinkResolver (config: ConfigurationFile) = member this.UriResolver = uriResolver member this.FetchLinks(ctx) = + // Clear existing entries to prevent duplicate key errors when called multiple times + this.LinkReferences.Clear() + this.DeclaredRepositories.Clear() + let redirects = RepositoryLinks.SerializeRedirects config.Redirects // language=json let json = $$"""{ @@ -66,7 +70,6 @@ type TestCrossLinkResolver (config: ConfigurationFile) = this.LinkReferences.Add("kibana", reference) this.DeclaredRepositories.Add("docs-content") |> ignore; this.DeclaredRepositories.Add("kibana") |> ignore; - this.DeclaredRepositories.Add("elasticsearch") |> ignore let indexEntries = this.LinkReferences.ToDictionary(_.Key, fun (e : KeyValuePair) -> LinkRegistryEntry(