From bf5747a9c2a402ffc29496e9883c51642d4f2eda Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Sat, 26 Jul 2025 01:10:12 +0200 Subject: [PATCH 01/18] Add crosslinks to toc --- docs/_docset.yml | 2 + .../Builder/TableOfContentsConfiguration.cs | 15 +++ .../TableOfContents/ITocItem.cs | 3 + src/Elastic.Markdown/IO/DocumentationSet.cs | 28 ++++++ .../IO/Navigation/CrossLinkNavigationItem.cs | 67 +++++++++++++ .../IO/Navigation/DocumentationGroup.cs | 8 +- .../NavigationCrossLinkValidator.cs | 94 +++++++++++++++++++ .../ConfigurationCrossLinkFetcher.cs | 39 +++++++- .../Links/CrossLinks/CrossLinkResolver.cs | 24 ++++- updatecli/updatecli.d/versions.yml | 0 10 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs create mode 100644 src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs create mode 100644 updatecli/updatecli.d/versions.yml diff --git a/docs/_docset.yml b/docs/_docset.yml index a09f0def2..828c075f3 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -149,3 +149,5 @@ toc: - folder: baz children: - file: qux.md + - title: "Getting Started Guide" + crosslink: docs-content://get-started/introduction.md \ No newline at end of file diff --git a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs index 907ee8e6b..f770ad88b 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,12 @@ private IReadOnlyCollection ReadChildren(YamlStreamReader reader, KeyV return [new FileReference(this, path, hiddenFile, children ?? [])]; } + if (crossLink is not null) + { + // No validation here - we'll validate cross-links separately + 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 5dc9886c4..8c24478ea 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -209,6 +209,25 @@ public DocumentationSet( .ToDictionary(kv => kv.Item1, kv => kv.Item2) .ToFrozenDictionary(); + // Validate cross-repo links in navigation + + try + { + // First ensure links are fetched - this is essential for resolving links properly + _ = LinkResolver.FetchLinks(new Cancel()).GetAwaiter().GetResult(); + + 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 +241,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 +264,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 +280,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..e4cef5d51 100644 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs @@ -119,7 +119,13 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex) foreach (var tocItem in lookups.TableOfContents) { - if (tocItem is FileReference file) + if (tocItem is CrossLinkReference crossLink) + { + // 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..20d429a40 --- /dev/null +++ b/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs @@ -0,0 +1,94 @@ +// 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) + { + // Ensure cross-links are fetched before validation + _ = await crossLinkResolver.FetchLinks(new Cancel()); + + // 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); + } + } + else if (item is FileNavigationItem fileItem && + fileItem.Url != null && + Uri.TryCreate(fileItem.Url, UriKind.Absolute, out var fileUri) && + fileUri.Scheme != "http" && + fileUri.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/updatecli/updatecli.d/versions.yml b/updatecli/updatecli.d/versions.yml new file mode 100644 index 000000000..e69de29bb From c1bb57d662c03bbff865cdd850fa48026311c001 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Sat, 26 Jul 2025 01:51:28 +0200 Subject: [PATCH 02/18] Fix errors --- docs/_docset.yml | 6 ++-- docs/configure/content-set/navigation.md | 28 ++++++++++++++++++- .../TestCrossLinkResolver.cs | 6 +++- .../Framework/TestCrossLinkResolver.fs | 5 +++- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/_docset.yml b/docs/_docset.yml index 828c075f3..a5681df38 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -149,5 +149,7 @@ toc: - folder: baz children: - file: qux.md - - title: "Getting Started Guide" - crosslink: docs-content://get-started/introduction.md \ No newline at end of file + - 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..793e5000c 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 + cross_link: docs-content://directory/file.md + - folder: local-section + children: + - file: index.md + - title: API Reference + cross_link: 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/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( From c92fb386872812dfd79ea353911a3474921fa8c4 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Sat, 26 Jul 2025 01:53:49 +0200 Subject: [PATCH 03/18] Update docs --- docs/configure/content-set/navigation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configure/content-set/navigation.md b/docs/configure/content-set/navigation.md index 793e5000c..d4b7cac73 100644 --- a/docs/configure/content-set/navigation.md +++ b/docs/configure/content-set/navigation.md @@ -94,12 +94,12 @@ Cross-links can also be included in navigation structures. When creating a `toc. toc: - file: index.md - title: External Documentation - cross_link: docs-content://directory/file.md + crosslink: docs-content://directory/file.md - folder: local-section children: - file: index.md - title: API Reference - cross_link: elasticsearch://api/index.html + 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. From b78c2c3c1cfba4625bcfbbeadc82ef09cf915ac7 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 28 Jul 2025 19:03:32 +0200 Subject: [PATCH 04/18] Add title validation --- .../Builder/TableOfContentsConfiguration.cs | 1 - src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs index f770ad88b..d0968990a 100644 --- a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs @@ -210,7 +210,6 @@ private IReadOnlyCollection ReadChildren(YamlStreamReader reader, KeyV if (crossLink is not null) { - // No validation here - we'll validate cross-links separately return [new CrossLinkReference(this, crossLink, title, hiddenFile, children ?? [])]; } diff --git a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs index e4cef5d51..1532fbbb6 100644 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs @@ -121,6 +121,12 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex) { 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); From 04920ab87c097a920f1205b577e64610d31047b9 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 28 Jul 2025 19:34:56 +0200 Subject: [PATCH 05/18] Add ctx for Cancel --- .../IO/Navigation/NavigationCrossLinkValidator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs b/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs index 20d429a40..a68b9286a 100644 --- a/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs +++ b/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs @@ -15,11 +15,11 @@ public static class NavigationCrossLinkValidator public static async Task ValidateNavigationCrossLinksAsync( INavigationItem root, ICrossLinkResolver crossLinkResolver, - Action errorEmitter) + Action errorEmitter, + Cancel ctx = default) { // Ensure cross-links are fetched before validation - _ = await crossLinkResolver.FetchLinks(new Cancel()); - + _ = await crossLinkResolver.FetchLinks(ctx); // Collect all navigation items that contain cross-repo links var itemsWithCrossLinks = FindNavigationItemsWithCrossLinks(root); From effe888f14005478ac2a957b2f56ef563be76621 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 28 Jul 2025 19:36:21 +0200 Subject: [PATCH 06/18] FileNavigationItem can be ignored --- .../IO/Navigation/NavigationCrossLinkValidator.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs b/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs index a68b9286a..edccb7843 100644 --- a/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs +++ b/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs @@ -73,14 +73,7 @@ private static List FindNavigationItemsWithCrossLinks(INavigati results.Add(item); } } - else if (item is FileNavigationItem fileItem && - fileItem.Url != null && - Uri.TryCreate(fileItem.Url, UriKind.Absolute, out var fileUri) && - fileUri.Scheme != "http" && - fileUri.Scheme != "https") - { - results.Add(item); - } // Recursively check children if this is a container + // Recursively check children if this is a container if (item is INodeNavigationItem containerItem) { foreach (var child in containerItem.NavigationItems) From 03ec2340181925c73739f6fe440ea86f9934787d Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 28 Jul 2025 19:40:30 +0200 Subject: [PATCH 07/18] Remove redundant code --- src/Elastic.Markdown/IO/DocumentationSet.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 8c24478ea..2b614aba9 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -213,9 +213,6 @@ public DocumentationSet( try { - // First ensure links are fetched - this is essential for resolving links properly - _ = LinkResolver.FetchLinks(new Cancel()).GetAwaiter().GetResult(); - NavigationCrossLinkValidator.ValidateNavigationCrossLinksAsync( Tree, LinkResolver, From 2567bf418b9ce357b97f9b4c5ca99677a4424d89 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 20 Aug 2025 12:31:22 +0200 Subject: [PATCH 08/18] Move routine --- src/Elastic.Markdown/IO/DocumentationSet.cs | 36 +++++++++++---------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 0c6febaec..42a9f17c4 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -209,22 +209,6 @@ 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(); } @@ -390,9 +374,27 @@ void ValidateExists(string from, string to, IReadOnlyDictionary 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); From a19d49b275e681d9371b9dc895f649b77f0dacd2 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 20 Aug 2025 12:43:07 +0200 Subject: [PATCH 09/18] Fix resolution --- src/Elastic.Markdown/DocumentationGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index b4133f990..dc22b20b4 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -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"); } From ac0284b903accb0202f3cfdf35236a017348a80b Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Thu, 21 Aug 2025 13:29:25 +0200 Subject: [PATCH 10/18] Fix hx-select-oob for nav crosslinks --- src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs | 3 +++ src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs | 1 + src/Elastic.Documentation.Site/Navigation/INavigationItem.cs | 3 +++ src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml | 3 ++- src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs | 1 + src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs | 2 ++ src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs | 1 + 7 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs index 9fb5c00ef..9c636420e 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs @@ -33,6 +33,7 @@ public class LandingNavigationItem : IApiGroupingNavigationItem NavigationItems { get; set; } = []; public INodeNavigationItem? 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; @@ -83,6 +84,7 @@ public abstract class ApiGroupingNavigationItem public bool Hidden => false; /// public int NavigationIndex { get; set; } + public bool IsCrossLink => false; // API grouping items are never cross-links /// public int Depth => 0; @@ -141,6 +143,7 @@ public class EndpointNavigationItem(ApiEndpoint endpoint, IRootNavigationItem public int NavigationIndex { get; set; } + public bool IsCrossLink => false; // API endpoint items are never cross-links /// public int Depth => 0; diff --git a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs index 1039603f9..1c0f51fdb 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs @@ -70,5 +70,6 @@ IApiGroupingNavigationItem parent public INodeNavigationItem? Parent { get; set; } public int NavigationIndex { get; set; } + public bool IsCrossLink => false; // API operations are never cross-links } diff --git a/src/Elastic.Documentation.Site/Navigation/INavigationItem.cs b/src/Elastic.Documentation.Site/Navigation/INavigationItem.cs index d225af36f..4ec006872 100644 --- a/src/Elastic.Documentation.Site/Navigation/INavigationItem.cs +++ b/src/Elastic.Documentation.Site/Navigation/INavigationItem.cs @@ -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. diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index 19fe3e250..fefab98fa 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -79,10 +79,11 @@ } else if (item is ILeafNavigationItem leaf) { + var hasSameTopLevelGroup = !leaf.IsCrossLink && (Model.IsPrimaryNavEnabled && leaf.NavigationRoot.Id == Model.RootNavigationId || true);
  • @leaf.NavigationTitle diff --git a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs b/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs index 1a98d63fd..11ba972aa 100644 --- a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs +++ b/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs @@ -63,5 +63,6 @@ private string GetNavigationTitleFromUrl(string url) 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 } diff --git a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs index 1532fbbb6..72ffe3c56 100644 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs @@ -32,6 +32,8 @@ public class DocumentationGroup : INodeNavigationItem false; // Documentation groups are never cross-links + private IReadOnlyCollection FilesInOrder { get; } private IReadOnlyCollection GroupsInOrder { get; } diff --git a/src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs b/src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs index 67df3d7a9..ff34bd750 100644 --- a/src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs +++ b/src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs @@ -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 } From 0a52f7541c03a82cafa6edcb452e3ebd30ca80b0 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 22 Aug 2025 11:47:40 +0200 Subject: [PATCH 11/18] Add validation and title as mandatory --- .../Builder/TableOfContentsConfiguration.cs | 23 +++++++++++++ .../IO/Navigation/CrossLinkNavigationItem.cs | 33 ++----------------- .../IO/Navigation/DocumentationGroup.cs | 18 ++++++++++ 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs index 63630faf3..3923c7cce 100644 --- a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs @@ -156,6 +156,22 @@ private IReadOnlyCollection ReadChildren(YamlStreamReader reader, KeyV case "crosslink": hiddenFile = false; crossLink = reader.ReadString(entry); + // Validate crosslink URI early + if (string.IsNullOrWhiteSpace(crossLink)) + { + reader.EmitError("Cross-link entries must specify a non-empty 'crosslink' URI", tocEntry); + crossLink = null; // Reset to prevent further processing + } + else if (!Uri.TryCreate(crossLink, UriKind.Absolute, out var parsedUri)) + { + reader.EmitError($"Cross-link URI '{crossLink}' is not a valid absolute URI format", tocEntry); + crossLink = null; // Reset to prevent further processing + } + else if (parsedUri.Scheme is "http" or "https" or "ftp" or "file") + { + reader.EmitError($"Cross-link URI '{crossLink}' cannot use standard web schemes (http, https, ftp, file). Use cross-repository schemes like 'docs-content://', 'kibana://', etc.", tocEntry); + crossLink = null; // Reset to prevent further processing + } break; case "folder": folder = ReadFolder(reader, entry, parentPath); @@ -174,6 +190,13 @@ private IReadOnlyCollection 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; + } + if (toc is not null) { foreach (var f in toc.Files) diff --git a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs b/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs index 11ba972aa..9168372c4 100644 --- a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs +++ b/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs @@ -13,44 +13,15 @@ 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) + public CrossLinkNavigationItem(string url, string title, DocumentationGroup group, bool hidden = false) { _url = url; - NavigationTitle = title ?? GetNavigationTitleFromUrl(url); + NavigationTitle = title; 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 diff --git a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs index 72ffe3c56..5a3ff7cd9 100644 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs @@ -123,12 +123,30 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex) { if (tocItem is CrossLinkReference crossLink) { + // Validate that cross-link URI is not empty + if (string.IsNullOrWhiteSpace(crossLink.CrossLinkUri)) + { + context.EmitError(context.ConfigurationPath, + "Cross-link entries must have a 'crosslink' URI specified."); + continue; + } + + // Validate that cross-link URI is a valid URI format + if (!Uri.TryCreate(crossLink.CrossLinkUri, UriKind.Absolute, out var parsedUri)) + { + context.EmitError(context.ConfigurationPath, + $"Cross-link URI '{crossLink.CrossLinkUri}' is not a valid absolute URI format."); + continue; + } + + // Validate that cross-link has a title 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); From 05c8f8cbbf141719c20faef3c66f3bb51ddc8104 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 22 Aug 2025 12:01:47 +0200 Subject: [PATCH 12/18] Add utility class for crosslink validation --- docs/_docset.yml | 9 ++- .../Builder/TableOfContentsConfiguration.cs | 24 +++---- .../Links/CrossLinkValidator.cs | 69 +++++++++++++++++++ .../IO/Navigation/DocumentationGroup.cs | 16 ++--- .../Links/CrossLinks/CrossLinkValidator.cs | 69 +++++++++++++++++++ .../DiagnosticLinkInlineParser.cs | 9 +-- 6 files changed, 160 insertions(+), 36 deletions(-) create mode 100644 src/Elastic.Documentation/Links/CrossLinkValidator.cs create mode 100644 src/Elastic.Markdown/Links/CrossLinks/CrossLinkValidator.cs diff --git a/docs/_docset.yml b/docs/_docset.yml index bacae3249..bfbd5018a 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -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 @@ -153,8 +156,4 @@ toc: - file: bar.md - 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 + - file: qux.md \ No newline at end of file diff --git a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs index 3923c7cce..797c40242 100644 --- a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs @@ -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; @@ -157,19 +158,9 @@ private IReadOnlyCollection ReadChildren(YamlStreamReader reader, KeyV hiddenFile = false; crossLink = reader.ReadString(entry); // Validate crosslink URI early - if (string.IsNullOrWhiteSpace(crossLink)) + if (!CrossLinkValidator.IsValidCrossLink(crossLink, out var errorMessage)) { - reader.EmitError("Cross-link entries must specify a non-empty 'crosslink' URI", tocEntry); - crossLink = null; // Reset to prevent further processing - } - else if (!Uri.TryCreate(crossLink, UriKind.Absolute, out var parsedUri)) - { - reader.EmitError($"Cross-link URI '{crossLink}' is not a valid absolute URI format", tocEntry); - crossLink = null; // Reset to prevent further processing - } - else if (parsedUri.Scheme is "http" or "https" or "ftp" or "file") - { - reader.EmitError($"Cross-link URI '{crossLink}' cannot use standard web schemes (http, https, ftp, file). Use cross-repository schemes like 'docs-content://', 'kibana://', etc.", tocEntry); + reader.EmitError(errorMessage!, tocEntry); crossLink = null; // Reset to prevent further processing } break; @@ -197,6 +188,15 @@ private IReadOnlyCollection ReadChildren(YamlStreamReader reader, KeyV 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) diff --git a/src/Elastic.Documentation/Links/CrossLinkValidator.cs b/src/Elastic.Documentation/Links/CrossLinkValidator.cs new file mode 100644 index 000000000..4c6195889 --- /dev/null +++ b/src/Elastic.Documentation/Links/CrossLinkValidator.cs @@ -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; + +/// +/// Utility class for validating and identifying cross-repository links +/// +public static class CrossLinkValidator +{ + /// + /// 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. + /// + private static readonly ImmutableHashSet ExcludedSchemes = + ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, + "http", "https", "ftp", "file", "tel", "jdbc", "mailto"); + + /// + /// Validates that a URI string is a valid cross-repository link. + /// + /// The URI string to validate + /// Error message if validation fails + /// True if valid crosslink, false otherwise + 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; + } + + /// + /// 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. + /// + /// The URI to check + /// True if this should be treated as a crosslink + public static bool IsCrossLink(Uri? uri) => + uri != null + && !ExcludedSchemes.Contains(uri.Scheme) + && !uri.IsFile + && !string.IsNullOrEmpty(uri.Scheme); + + /// + /// Gets the list of excluded URI schemes for reference + /// + public static IReadOnlySet GetExcludedSchemes() => ExcludedSchemes; +} diff --git a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs index 5a3ff7cd9..eb208382e 100644 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.TableOfContents; using Elastic.Documentation.Extensions; +using Elastic.Documentation.Links; using Elastic.Documentation.Site.Navigation; namespace Elastic.Markdown.IO.Navigation; @@ -123,19 +124,10 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex) { if (tocItem is CrossLinkReference crossLink) { - // Validate that cross-link URI is not empty - if (string.IsNullOrWhiteSpace(crossLink.CrossLinkUri)) + // Validate crosslink URI and title + if (!CrossLinkValidator.IsValidCrossLink(crossLink.CrossLinkUri, out var errorMessage)) { - context.EmitError(context.ConfigurationPath, - "Cross-link entries must have a 'crosslink' URI specified."); - continue; - } - - // Validate that cross-link URI is a valid URI format - if (!Uri.TryCreate(crossLink.CrossLinkUri, UriKind.Absolute, out var parsedUri)) - { - context.EmitError(context.ConfigurationPath, - $"Cross-link URI '{crossLink.CrossLinkUri}' is not a valid absolute URI format."); + context.EmitError(context.ConfigurationPath, errorMessage!); continue; } diff --git a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkValidator.cs b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkValidator.cs new file mode 100644 index 000000000..565917d84 --- /dev/null +++ b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkValidator.cs @@ -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.Markdown.Links.CrossLinks; + +/// +/// Utility class for validating and identifying cross-repository links +/// +public static class CrossLinkValidator +{ + /// + /// 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. + /// + private static readonly ImmutableHashSet ExcludedSchemes = + ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, + "http", "https", "ftp", "file", "tel", "jdbc", "mailto"); + + /// + /// Validates that a URI string is a valid cross-repository link. + /// + /// The URI string to validate + /// Error message if validation fails + /// True if valid crosslink, false otherwise + 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; + } + + /// + /// 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. + /// + /// The URI to check + /// True if this should be treated as a crosslink + public static bool IsCrossLink(Uri? uri) => + uri != null + && !ExcludedSchemes.Contains(uri.Scheme) + && !uri.IsFile + && !string.IsNullOrEmpty(uri.Scheme); + + /// + /// Gets the list of excluded URI schemes for reference + /// + public static IReadOnlySet GetExcludedSchemes() => ExcludedSchemes; +} diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index e76bfb477..68b7a9e22 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -7,6 +7,7 @@ using System.IO.Abstractions; using System.Runtime.InteropServices; using System.Text.RegularExpressions; +using Elastic.Documentation.Links; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Helpers; using Elastic.Markdown.IO; @@ -46,9 +47,6 @@ internal sealed partial class LinkRegexExtensions public class DiagnosticLinkInlineParser : LinkInlineParser { - // See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml for a list of URI schemes - private static readonly ImmutableHashSet ExcludedSchemes = ["http", "https", "tel", "jdbc", "mailto"]; - public override bool Match(InlineProcessor processor, ref StringSlice slice) { var match = base.Match(processor, ref slice); @@ -389,8 +387,5 @@ public static string UpdateRelativeUrl(ParserContext context, string url) } private static bool IsCrossLink([NotNullWhen(true)] Uri? uri) => - uri != null // This means it's not a local - && !ExcludedSchemes.Contains(uri.Scheme) - && !uri.IsFile - && !string.IsNullOrEmpty(uri.Scheme); + CrossLinkValidator.IsCrossLink(uri); } From f51258db9d3c3e4c8d562e0ae6689d531cf7e171 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 22 Aug 2025 12:15:23 +0200 Subject: [PATCH 13/18] Remove redundant file --- .../Links/CrossLinks/CrossLinkValidator.cs | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 src/Elastic.Markdown/Links/CrossLinks/CrossLinkValidator.cs diff --git a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkValidator.cs b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkValidator.cs deleted file mode 100644 index 565917d84..000000000 --- a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkValidator.cs +++ /dev/null @@ -1,69 +0,0 @@ -// 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.Markdown.Links.CrossLinks; - -/// -/// Utility class for validating and identifying cross-repository links -/// -public static class CrossLinkValidator -{ - /// - /// 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. - /// - private static readonly ImmutableHashSet ExcludedSchemes = - ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, - "http", "https", "ftp", "file", "tel", "jdbc", "mailto"); - - /// - /// Validates that a URI string is a valid cross-repository link. - /// - /// The URI string to validate - /// Error message if validation fails - /// True if valid crosslink, false otherwise - 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; - } - - /// - /// 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. - /// - /// The URI to check - /// True if this should be treated as a crosslink - public static bool IsCrossLink(Uri? uri) => - uri != null - && !ExcludedSchemes.Contains(uri.Scheme) - && !uri.IsFile - && !string.IsNullOrEmpty(uri.Scheme); - - /// - /// Gets the list of excluded URI schemes for reference - /// - public static IReadOnlySet GetExcludedSchemes() => ExcludedSchemes; -} From 7d3d364b32cb451fb4c46da6fb79852eb61dd594 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 22 Aug 2025 12:18:55 +0200 Subject: [PATCH 14/18] Refactor NavCrossLinkValidator --- .../IO/Navigation/NavigationCrossLinkValidator.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs b/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs index edccb7843..717472576 100644 --- a/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs +++ b/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Elastic.Documentation.Links; using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.Links.CrossLinks; @@ -29,7 +30,7 @@ public static async Task ValidateNavigationCrossLinksAsync( { var url = crossLinkItem.Url; if (url != null && Uri.TryCreate(url, UriKind.Absolute, out var crossUri) && - crossUri.Scheme != "http" && crossUri.Scheme != "https") + CrossLinkValidator.IsCrossLink(crossUri)) { // Try to resolve the cross-link URL if (crossLinkResolver.TryResolve(errorEmitter, crossUri, out var resolvedUri)) @@ -47,8 +48,7 @@ public static async Task ValidateNavigationCrossLinksAsync( else if (item is FileNavigationItem fileItem && fileItem.Url != null && Uri.TryCreate(fileItem.Url, UriKind.Absolute, out var fileUri) && - fileUri.Scheme != "http" && - fileUri.Scheme != "https") + CrossLinkValidator.IsCrossLink(fileUri)) { // Cross-link URL detected in a FileNavigationItem, but we're not validating it yet } @@ -67,8 +67,7 @@ private static List FindNavigationItemsWithCrossLinks(INavigati var url = crossLinkItem.Url; if (url != null && Uri.TryCreate(url, UriKind.Absolute, out var uri) && - uri.Scheme != "http" && - uri.Scheme != "https") + CrossLinkValidator.IsCrossLink(uri)) { results.Add(item); } From 05cc209ee2374b0fb01880185048652a7a1a7c17 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 26 Aug 2025 19:13:01 +0200 Subject: [PATCH 15/18] Ensure we inject docs-builder on CI for integration tests as well --- .../Assembler/AssemblyConfiguration.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs b/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs index 1a04094c8..240aa9ed3 100644 --- a/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Assembler/AssemblyConfiguration.cs @@ -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 ) From 4f9ca60abf7e908d9694ab2bdff9d4974c4a33b1 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 26 Aug 2025 19:32:31 +0200 Subject: [PATCH 16/18] allow docs-builder to have local checkout folder on CI --- .../docs-assembler/Sourcing/RepositorySourcesFetcher.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tooling/docs-assembler/Sourcing/RepositorySourcesFetcher.cs b/src/tooling/docs-assembler/Sourcing/RepositorySourcesFetcher.cs index ccadeca05..5eabaa616 100644 --- a/src/tooling/docs-assembler/Sourcing/RepositorySourcesFetcher.cs +++ b/src/tooling/docs-assembler/Sourcing/RepositorySourcesFetcher.cs @@ -35,8 +35,8 @@ public CheckoutResult GetAll() foreach (var repo in repositories.Values) { var checkoutFolder = fs.DirectoryInfo.New(Path.Combine(context.CheckoutDirectory.FullName, repo.Name)); - // if we are running locally, allow for repository path override - if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CI")) && !string.IsNullOrWhiteSpace(repo.Path)) + // if we are running locally, always allow repository path overrides. Otherwise, only for docs-builder. + if (!string.IsNullOrWhiteSpace(repo.Path) && (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CI")) || repo.Name == "docs-builder")) { _logger.LogInformation("{RepositoryName}: Using local override path for {RepositoryName} at {Path}", repo.Name, repo.Name, repo.Path); checkoutFolder = fs.DirectoryInfo.New(repo.Path); From cca7d09f0e425acc52f9e9ffbf704092daa76b8f Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 26 Aug 2025 19:44:36 +0200 Subject: [PATCH 17/18] Ensure we hadnle CrossLinkNavigationItem when building the sitemap by ignoring them --- src/tooling/docs-assembler/Building/SitemapBuilder.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tooling/docs-assembler/Building/SitemapBuilder.cs b/src/tooling/docs-assembler/Building/SitemapBuilder.cs index fe51ba057..13423b1c0 100644 --- a/src/tooling/docs-assembler/Building/SitemapBuilder.cs +++ b/src/tooling/docs-assembler/Building/SitemapBuilder.cs @@ -76,6 +76,8 @@ private static IReadOnlyCollection GetNavigationItems(IReadOnly result.AddRange(GetNavigationItems(group.NavigationItems)); result.Add(group); break; + case CrossLinkNavigationItem: + continue; // we do not emit cross links in the sitemap default: throw new Exception($"Unhandled navigation item type: {item.GetType()}"); } From 2a3a20a34be3bc07a1636c31dcbc4d7b8f0cc65e Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 27 Aug 2025 13:26:59 +0200 Subject: [PATCH 18/18] Remove `Fetch` from CrossLinkResolver, enforce eager fetching of crosslinks (#1784) * Remove `Fetch` from CrossLinkResolver, enforce eager fetching of crosslinks. This removes a hidden requirement on `DocumentationSet` that its resolver is not usable until `DocumentationGenerator.GenerateAll()` has been called. We now enforce `DocumentationSet` receives a `ICrossLinkResolver` that is ready to resolve crosslinks. * Found more cases of unhandled navigation types * Remove new caching behavior in DocSetConfigurationCrossLinkFetcher --- docs-builder.sln.DotSettings | 1 + .../Builder/TableOfContentsConfiguration.cs | 5 +- .../TableOfContents/ITocItem.cs | 2 +- .../DocumentationGenerator.cs | 7 +- src/Elastic.Markdown/IO/DocumentationSet.cs | 41 +++------ src/Elastic.Markdown/IO/MarkdownFile.cs | 4 +- .../IO/Navigation/CrossLinkNavigationItem.cs | 17 ++-- .../IO/Navigation/DocumentationGroup.cs | 14 ++- .../NavigationCrossLinkValidator.cs | 86 ------------------ .../Links/CrossLinks/CrossLinkFetcher.cs | 30 +++---- .../Links/CrossLinks/CrossLinkResolver.cs | 27 ++++-- ...=> DocSetConfigurationCrossLinkFetcher.cs} | 14 +-- .../InboundLinks/LinkIndexCrossLinkFetcher.cs | 8 +- .../InboundLinks/LinkIndexLinkChecker.cs | 16 ++-- src/tooling/docs-assembler/AssembleSources.cs | 41 ++++++--- .../Building/AssemblerBuilder.cs | 2 +- .../Building/AssemblerCrossLinkFetcher.cs | 7 +- .../docs-assembler/Building/SitemapBuilder.cs | 4 +- .../Cli/ContentSourceCommands.cs | 2 +- .../docs-assembler/Cli/RepositoryCommands.cs | 10 ++- .../Links/NavigationPrefixChecker.cs | 6 +- .../Navigation/AssemblerDocumentationSet.cs | 2 +- .../Navigation/GlobalNavigation.cs | 13 ++- .../Navigation/GlobalNavigationFile.cs | 2 +- src/tooling/docs-builder/Cli/Commands.cs | 10 ++- .../docs-builder/Http/DocumentationWebHost.cs | 4 +- .../Http/ReloadGeneratorService.cs | 2 - .../Http/ReloadableGeneratorState.cs | 49 +++++++--- .../Directives/DirectiveBaseTests.cs | 1 - .../Inline/InlneBaseTests.cs | 1 - .../TestCrossLinkResolver.cs | 29 +++--- .../Framework/CrossLinkResolverAssertions.fs | 1 - .../Framework/TestCrossLinkResolver.fs | 90 ++++++++----------- 33 files changed, 241 insertions(+), 307 deletions(-) delete mode 100644 src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs rename src/Elastic.Markdown/Links/CrossLinks/{ConfigurationCrossLinkFetcher.cs => DocSetConfigurationCrossLinkFetcher.cs} (74%) diff --git a/docs-builder.sln.DotSettings b/docs-builder.sln.DotSettings index 2cc897060..571a4f3ad 100644 --- a/docs-builder.sln.DotSettings +++ b/docs-builder.sln.DotSettings @@ -1,4 +1,5 @@  + True True True True diff --git a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs index 797c40242..346734fbe 100644 --- a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs @@ -233,7 +233,10 @@ file is null && crossLink is null && folder is null && toc is null && if (crossLink is not null) { - return [new CrossLinkReference(this, crossLink, title, hiddenFile, children ?? [])]; + 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) diff --git a/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs b/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs index 29ea93ca4..8303dace5 100644 --- a/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs +++ b/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs @@ -14,7 +14,7 @@ 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) +public record CrossLinkReference(ITableOfContentsScope TableOfContentsScope, Uri CrossLinkUri, string? Title, bool Hidden, IReadOnlyCollection Children) : ITocItem; public record FolderReference(ITableOfContentsScope TableOfContentsScope, string RelativePath, IReadOnlyCollection Children) diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index bba15ee58..df6275aff 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -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( @@ -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 = @@ -120,9 +120,6 @@ public async Task 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); diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 42a9f17c4..e955546c9 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -10,7 +10,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Configuration.TableOfContents; -using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links; using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.Extensions; @@ -28,6 +27,7 @@ public interface INavigationLookups IReadOnlyCollection TableOfContents { get; } IReadOnlyCollection EnabledExtensions { get; } FrozenDictionary FilesGroupedByFolder { get; } + ICrossLinkResolver CrossLinkResolver { get; } } public interface IPositionalNavigation @@ -94,10 +94,12 @@ public record NavigationLookups : INavigationLookups public required IReadOnlyCollection TableOfContents { get; init; } public required IReadOnlyCollection EnabledExtensions { get; init; } public required FrozenDictionary FilesGroupedByFolder { get; init; } + public required ICrossLinkResolver CrossLinkResolver { get; init; } } public class DocumentationSet : INavigationLookups, IPositionalNavigation { + private readonly ILogger _logger; public BuildContext Context { get; } public string Name { get; } public IFileInfo OutputStateFile { get; } @@ -112,7 +114,7 @@ public class DocumentationSet : INavigationLookups, IPositionalNavigation public MarkdownParser MarkdownParser { get; } - public ICrossLinkResolver LinkResolver { get; } + public ICrossLinkResolver CrossLinkResolver { get; } public TableOfContentsTree Tree { get; } @@ -135,23 +137,23 @@ public class DocumentationSet : INavigationLookups, IPositionalNavigation public DocumentationSet( BuildContext context, ILoggerFactory logFactory, - ICrossLinkResolver? linkResolver = null, + ICrossLinkResolver linkResolver, TableOfContentsTreeCollector? treeCollector = null ) { + _logger = logFactory.CreateLogger(); Context = context; Source = ContentSourceMoniker.Create(context.Git.RepositoryName, null); SourceDirectory = context.DocumentationSourceDirectory; OutputDirectory = context.OutputDirectory; - LinkResolver = - linkResolver ?? new CrossLinkResolver(new ConfigurationCrossLinkFetcher(logFactory, context.Configuration, Aws3LinkIndexReader.CreateAnonymous())); + CrossLinkResolver = linkResolver; Configuration = context.Configuration; EnabledExtensions = InstantiateExtensions(); treeCollector ??= new TableOfContentsTreeCollector(); var resolver = new ParserResolvers { - CrossLinkResolver = LinkResolver, + CrossLinkResolver = CrossLinkResolver, DocumentationFileLookup = DocumentationFileLookup }; MarkdownParser = new MarkdownParser(context, resolver); @@ -184,7 +186,8 @@ public DocumentationSet( FlatMappedFiles = FlatMappedFiles, TableOfContents = Configuration.TableOfContents, EnabledExtensions = EnabledExtensions, - FilesGroupedByFolder = FilesGroupedByFolder + FilesGroupedByFolder = FilesGroupedByFolder, + CrossLinkResolver = CrossLinkResolver }; Tree = new TableOfContentsTree(Source, Context, lookups, treeCollector, ref fileIndex); @@ -232,7 +235,7 @@ private void UpdateNavigationIndex(IReadOnlyCollection navigati UpdateNavigationIndex(documentationGroup.NavigationItems, ref navigationIndex); break; default: - Context.EmitError(Context.ConfigurationPath, $"Unhandled navigation item type: {item.GetType()}"); + Context.EmitError(Context.ConfigurationPath, $"{nameof(DocumentationSet)}.{nameof(UpdateNavigationIndex)}: Unhandled navigation item type: {item.GetType()}"); break; } } @@ -374,26 +377,7 @@ void ValidateExists(string from, string to, IReadOnlyDictionary return FlatMappedFiles.GetValueOrDefault(relativePath); } - 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}"); - } - } + public async Task ResolveDirectoryTree(Cancel ctx) => await Tree.Resolve(ctx); private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext context) { @@ -476,6 +460,7 @@ public RepositoryLinks CreateLinkReference() public void ClearOutputDirectory() { + _logger.LogInformation("Clearing output directory {OutputDirectory}", OutputDirectory.Name); if (OutputDirectory.Exists) OutputDirectory.Delete(true); OutputDirectory.Create(); diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index b5b738d56..b288d025f 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -121,13 +121,13 @@ public string Url { if (_url is not null) return _url; - if (_set.LinkResolver.UriResolver is IsolatedBuildEnvironmentUriResolver) + if (_set.CrossLinkResolver.UriResolver is IsolatedBuildEnvironmentUriResolver) { _url = DefaultUrlPath; return _url; } var crossLink = new Uri(CrossLink); - var uri = _set.LinkResolver.UriResolver.Resolve(crossLink, DefaultUrlPathSuffix); + var uri = _set.CrossLinkResolver.UriResolver.Resolve(crossLink, DefaultUrlPathSuffix); _url = uri.AbsolutePath; return _url; diff --git a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs b/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs index 9168372c4..6dd24c352 100644 --- a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs +++ b/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs @@ -11,11 +11,10 @@ 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) + public CrossLinkNavigationItem(Uri crossLinkUri, Uri resolvedUrl, string title, DocumentationGroup group, bool hidden = false) { - _url = url; + CrossLink = crossLinkUri; + Url = resolvedUrl.ToString(); NavigationTitle = title; Parent = group; NavigationRoot = group.NavigationRoot; @@ -24,14 +23,10 @@ public CrossLinkNavigationItem(string url, string title, DocumentationGroup grou 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 Uri CrossLink { get; } + public string Url { get; } + public string NavigationTitle { get; } public int NavigationIndex { get; set; } public bool Hidden { get; } public bool IsCrossLink => true; // This is always a cross-link diff --git a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs index eb208382e..fd121a2c5 100644 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs @@ -7,7 +7,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.TableOfContents; using Elastic.Documentation.Extensions; -using Elastic.Documentation.Links; using Elastic.Documentation.Site.Navigation; namespace Elastic.Markdown.IO.Navigation; @@ -124,13 +123,6 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex) { if (tocItem is CrossLinkReference crossLink) { - // Validate crosslink URI and title - if (!CrossLinkValidator.IsValidCrossLink(crossLink.CrossLinkUri, out var errorMessage)) - { - context.EmitError(context.ConfigurationPath, errorMessage!); - continue; - } - // Validate that cross-link has a title if (string.IsNullOrWhiteSpace(crossLink.Title)) { @@ -139,9 +131,13 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex) continue; } + if (!lookups.CrossLinkResolver.TryResolve(msg => context.EmitError(context.ConfigurationPath, msg), crossLink.CrossLinkUri, out var resolvedUrl)) + continue; // the crosslink resolver will emit an error already + // Create a special navigation item for cross-repository links - var crossLinkItem = new CrossLinkNavigationItem(crossLink.CrossLinkUri, crossLink.Title, this, crossLink.Hidden); + var crossLinkItem = new CrossLinkNavigationItem(crossLink.CrossLinkUri, resolvedUrl, crossLink.Title, this, crossLink.Hidden); AddToNavigationItems(crossLinkItem, ref fileIndex); + } else if (tocItem is FileReference file) { diff --git a/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs b/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs deleted file mode 100644 index 717472576..000000000 --- a/src/Elastic.Markdown/IO/Navigation/NavigationCrossLinkValidator.cs +++ /dev/null @@ -1,86 +0,0 @@ -// 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.Links; -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) && - CrossLinkValidator.IsCrossLink(crossUri)) - { - // 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) && - CrossLinkValidator.IsCrossLink(fileUri)) - { - // 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) && - CrossLinkValidator.IsCrossLink(uri)) - { - 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/CrossLinkFetcher.cs b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkFetcher.cs index 4392a6480..7b0af06c8 100644 --- a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkFetcher.cs @@ -19,46 +19,43 @@ public record FetchedCrossLinks public required HashSet DeclaredRepositories { get; init; } - public required bool FromConfiguration { get; init; } - public required FrozenDictionary LinkIndexEntries { get; init; } public static FetchedCrossLinks Empty { get; } = new() { DeclaredRepositories = [], LinkReferences = new Dictionary().ToFrozenDictionary(), - FromConfiguration = false, LinkIndexEntries = new Dictionary().ToFrozenDictionary() }; } public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider) : IDisposable { - private readonly ILogger _logger = logFactory.CreateLogger(nameof(CrossLinkFetcher)); + protected ILogger Logger { get; } = logFactory.CreateLogger(nameof(CrossLinkFetcher)); private readonly HttpClient _client = new(); private LinkRegistry? _linkIndex; public static RepositoryLinks Deserialize(string json) => JsonSerializer.Deserialize(json, SourceGenerationContext.Default.RepositoryLinks)!; - public abstract Task Fetch(Cancel ctx); + public abstract Task FetchCrossLinks(Cancel ctx); - public async Task FetchLinkIndex(Cancel ctx) + public async Task FetchLinkRegistry(Cancel ctx) { if (_linkIndex is not null) { - _logger.LogTrace("Using cached link index"); + Logger.LogTrace("Using cached link index registry (link-index.json)"); return _linkIndex; } - _logger.LogInformation("Getting link index"); + Logger.LogInformation("Fetching link index registry (link-index.json)"); _linkIndex = await linkIndexProvider.GetRegistry(ctx); return _linkIndex; } protected async Task GetLinkIndexEntry(string repository, Cancel ctx) { - var linkIndex = await FetchLinkIndex(ctx); + var linkIndex = await FetchLinkRegistry(ctx); if (!linkIndex.Repositories.TryGetValue(repository, out var repositoryLinks)) throw new Exception($"Repository {repository} not found in link index"); return GetNextContentSourceLinkIndexEntry(repositoryLinks, repository); @@ -74,9 +71,9 @@ protected static LinkRegistryEntry GetNextContentSourceLinkIndexEntry(IDictionar return linkIndexEntry; } - protected async Task Fetch(string repository, string[] keys, Cancel ctx) + protected async Task FetchCrossLinks(string repository, string[] keys, Cancel ctx) { - var linkIndex = await FetchLinkIndex(ctx); + var linkIndex = await FetchLinkRegistry(ctx); if (!linkIndex.Repositories.TryGetValue(repository, out var repositoryLinks)) throw new Exception($"Repository {repository} not found in link index"); @@ -91,12 +88,15 @@ protected async Task Fetch(string repository, string[] keys, Ca protected async Task FetchLinkIndexEntry(string repository, LinkRegistryEntry linkRegistryEntry, Cancel ctx) { + var url = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/{linkRegistryEntry.Path}"; var linkReference = await TryGetCachedLinkReference(repository, linkRegistryEntry); if (linkReference is not null) + { + Logger.LogInformation("Using locally cached links.json for '{Repository}': {Url}", repository, url); return linkReference; + } - var url = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/{linkRegistryEntry.Path}"; - _logger.LogInformation("Fetching links.json for '{Repository}': {Url}", repository, url); + Logger.LogInformation("Fetching links.json for '{Repository}': {Url}", repository, url); var json = await _client.GetStringAsync(url, ctx); linkReference = Deserialize(json); WriteLinksJsonCachedFile(repository, linkRegistryEntry, json); @@ -116,7 +116,7 @@ private void WriteLinksJsonCachedFile(string repository, LinkRegistryEntry linkR } catch (Exception e) { - _logger.LogError(e, "Failed to write cached link reference {CachedPath}", cachedPath); + Logger.LogError(e, "Failed to write cached link reference {CachedPath}", cachedPath); } } @@ -140,7 +140,7 @@ private void WriteLinksJsonCachedFile(string repository, LinkRegistryEntry linkR } catch (Exception e) { - _logger.LogError(e, "Failed to read cached link reference {CachedPath}", cachedPath); + Logger.LogError(e, "Failed to read cached link reference {CachedPath}", cachedPath); return null; } } diff --git a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs index 72de952cb..eacafcbdf 100644 --- a/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Markdown/Links/CrossLinks/CrossLinkResolver.cs @@ -10,22 +10,33 @@ namespace Elastic.Markdown.Links.CrossLinks; public interface ICrossLinkResolver { - Task FetchLinks(Cancel ctx); bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri); IUriEnvironmentResolver UriResolver { get; } } -public class CrossLinkResolver(CrossLinkFetcher fetcher, IUriEnvironmentResolver? uriResolver = null) : ICrossLinkResolver +public class NoopCrossLinkResolver : ICrossLinkResolver { - private FetchedCrossLinks _crossLinks = FetchedCrossLinks.Empty; - public IUriEnvironmentResolver UriResolver { get; } = uriResolver ?? new IsolatedBuildEnvironmentUriResolver(); + public static NoopCrossLinkResolver Instance { get; } = new(); - public async Task FetchLinks(Cancel ctx) + /// + public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) { - _crossLinks = await fetcher.Fetch(ctx); - return _crossLinks; + resolvedUri = null; + return false; } + /// + public IUriEnvironmentResolver UriResolver { get; } = new IsolatedBuildEnvironmentUriResolver(); + + private NoopCrossLinkResolver() { } + +} + +public class CrossLinkResolver(FetchedCrossLinks crossLinks, IUriEnvironmentResolver? uriResolver = null) : ICrossLinkResolver +{ + private FetchedCrossLinks _crossLinks = crossLinks; + public IUriEnvironmentResolver UriResolver { get; } = uriResolver ?? new IsolatedBuildEnvironmentUriResolver(); + public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => TryResolve(errorEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri); @@ -50,7 +61,7 @@ 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 + // 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)) diff --git a/src/Elastic.Markdown/Links/CrossLinks/ConfigurationCrossLinkFetcher.cs b/src/Elastic.Markdown/Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs similarity index 74% rename from src/Elastic.Markdown/Links/CrossLinks/ConfigurationCrossLinkFetcher.cs rename to src/Elastic.Markdown/Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs index 4ef1ee4b1..5c0c50372 100644 --- a/src/Elastic.Markdown/Links/CrossLinks/ConfigurationCrossLinkFetcher.cs +++ b/src/Elastic.Markdown/Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs @@ -11,12 +11,15 @@ namespace Elastic.Markdown.Links.CrossLinks; -public class ConfigurationCrossLinkFetcher(ILoggerFactory logFactory, ConfigurationFile configuration, ILinkIndexReader linkIndexProvider) : CrossLinkFetcher(logFactory, linkIndexProvider) +/// Fetches cross-links from all the declared repositories in the docset.yml configuration see +public class DocSetConfigurationCrossLinkFetcher(ILoggerFactory logFactory, ConfigurationFile configuration, ILinkIndexReader? linkIndexProvider = null) + : CrossLinkFetcher(logFactory, linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous()) { - private readonly ILogger _logger = logFactory.CreateLogger(nameof(ConfigurationCrossLinkFetcher)); + private readonly ILogger _logger = logFactory.CreateLogger(nameof(DocSetConfigurationCrossLinkFetcher)); - public override async Task Fetch(Cancel ctx) + public override async Task FetchCrossLinks(Cancel ctx) { + Logger.LogInformation("Fetching cross-links for all repositories defined in docset.yml"); var linkReferences = new Dictionary(); var linkIndexEntries = new Dictionary(); var declaredRepositories = new HashSet(); @@ -26,7 +29,7 @@ public override async Task Fetch(Cancel ctx) _ = declaredRepositories.Add(repository); try { - var linkReference = await Fetch(repository, ["main", "master"], ctx); + var linkReference = await FetchCrossLinks(repository, ["main", "master"], ctx); linkReferences.Add(repository, linkReference); var linkIndexReference = await GetLinkIndexEntry(repository, ctx); @@ -62,9 +65,6 @@ public override async Task Fetch(Cancel ctx) DeclaredRepositories = declaredRepositories, LinkReferences = linkReferences.ToFrozenDictionary(), LinkIndexEntries = linkIndexEntries.ToFrozenDictionary(), - FromConfiguration = true }; } - - } diff --git a/src/Elastic.Markdown/Links/InboundLinks/LinkIndexCrossLinkFetcher.cs b/src/Elastic.Markdown/Links/InboundLinks/LinkIndexCrossLinkFetcher.cs index d6ee71686..3f68adec5 100644 --- a/src/Elastic.Markdown/Links/InboundLinks/LinkIndexCrossLinkFetcher.cs +++ b/src/Elastic.Markdown/Links/InboundLinks/LinkIndexCrossLinkFetcher.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.Collections.Frozen; +using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links; using Elastic.Markdown.Links.CrossLinks; @@ -10,14 +11,16 @@ namespace Elastic.Markdown.Links.InboundLinks; +/// fetches cross-links for all the repositories defined in the publicized link-index.json file using the content source public class LinksIndexCrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider) : CrossLinkFetcher(logFactory, linkIndexProvider) { - public override async Task Fetch(Cancel ctx) + public override async Task FetchCrossLinks(Cancel ctx) { + Logger.LogInformation("Fetching cross-links for all repositories defined in publicized link-index.json link index registry"); var linkReferences = new Dictionary(); var linkEntries = new Dictionary(); var declaredRepositories = new HashSet(); - var linkIndex = await FetchLinkIndex(ctx); + var linkIndex = await FetchLinkRegistry(ctx); foreach (var (repository, value) in linkIndex.Repositories) { var linkIndexEntry = GetNextContentSourceLinkIndexEntry(value, repository); @@ -33,7 +36,6 @@ public override async Task Fetch(Cancel ctx) DeclaredRepositories = declaredRepositories, LinkReferences = linkReferences.ToFrozenDictionary(), LinkIndexEntries = linkEntries.ToFrozenDictionary(), - FromConfiguration = false }; } diff --git a/src/Elastic.Markdown/Links/InboundLinks/LinkIndexLinkChecker.cs b/src/Elastic.Markdown/Links/InboundLinks/LinkIndexLinkChecker.cs index ae58eb07b..6d2e630a1 100644 --- a/src/Elastic.Markdown/Links/InboundLinks/LinkIndexLinkChecker.cs +++ b/src/Elastic.Markdown/Links/InboundLinks/LinkIndexLinkChecker.cs @@ -26,8 +26,8 @@ private sealed record RepositoryFilter public async Task CheckAll(IDiagnosticsCollector collector, Cancel ctx) { var fetcher = new LinksIndexCrossLinkFetcher(logFactory, _linkIndexProvider); - var resolver = new CrossLinkResolver(fetcher); - var crossLinks = await resolver.FetchLinks(ctx); + var crossLinks = await fetcher.FetchCrossLinks(ctx); + var resolver = new CrossLinkResolver(crossLinks); ValidateCrossLinks(collector, crossLinks, resolver, RepositoryFilter.None); } @@ -35,8 +35,8 @@ public async Task CheckAll(IDiagnosticsCollector collector, Cancel ctx) public async Task CheckRepository(IDiagnosticsCollector collector, string? toRepository, string? fromRepository, Cancel ctx) { var fetcher = new LinksIndexCrossLinkFetcher(logFactory, _linkIndexProvider); - var resolver = new CrossLinkResolver(fetcher); - var crossLinks = await resolver.FetchLinks(ctx); + var crossLinks = await fetcher.FetchCrossLinks(ctx); + var resolver = new CrossLinkResolver(crossLinks); var filter = new RepositoryFilter { LinksTo = toRepository, @@ -49,9 +49,8 @@ public async Task CheckRepository(IDiagnosticsCollector collector, string? toRep public async Task CheckWithLocalLinksJson(IDiagnosticsCollector collector, string repository, string localLinksJson, Cancel ctx) { var fetcher = new LinksIndexCrossLinkFetcher(logFactory, _linkIndexProvider); - var resolver = new CrossLinkResolver(fetcher); - // ReSharper disable once RedundantAssignment - var crossLinks = await resolver.FetchLinks(ctx); + var crossLinks = await fetcher.FetchCrossLinks(ctx); + var resolver = new CrossLinkResolver(crossLinks); if (string.IsNullOrEmpty(repository)) throw new ArgumentNullException(nameof(repository)); if (string.IsNullOrEmpty(localLinksJson)) @@ -105,8 +104,7 @@ RepositoryFilter filter foreach (var crossLink in linkReference.CrossLinks) { - // if we are filtering we only want errors from inbound links to a certain - // repository + // if we are filtering, we only want errors from inbound links to a certain repository var uri = new Uri(crossLink); if (filter.LinksTo != null && uri.Scheme != filter.LinksTo) continue; diff --git a/src/tooling/docs-assembler/AssembleSources.cs b/src/tooling/docs-assembler/AssembleSources.cs index a57084b2e..835cc9415 100644 --- a/src/tooling/docs-assembler/AssembleSources.cs +++ b/src/tooling/docs-assembler/AssembleSources.cs @@ -42,7 +42,7 @@ public class AssembleSources public FrozenDictionary NavigationTocMappings { get; } - public FrozenDictionary> HistoryMappings { get; } + public FrozenDictionary> LegacyUrlMappings { get; } public FrozenDictionary TocConfigurationMapping { get; } @@ -59,7 +59,26 @@ public static async Task AssembleAsync( Cancel ctx ) { - var sources = new AssembleSources(logFactory, context, checkouts, configurationContext, availableExporters); + var linkIndexProvider = Aws3LinkIndexReader.CreateAnonymous(); + var navigationTocMappings = GetTocMappings(context); + var legacyUrlMappings = GetLegacyUrlMappings(context); + var uriResolver = new PublishEnvironmentUriResolver(navigationTocMappings, context.Environment); + + var crossLinkFetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexProvider); + var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx); + var crossLinkResolver = new CrossLinkResolver(crossLinks, uriResolver); + + var sources = new AssembleSources( + logFactory, + context, + checkouts, + configurationContext, + navigationTocMappings, + legacyUrlMappings, + uriResolver, + crossLinkResolver, + availableExporters + ); foreach (var (_, set) in sources.AssembleSets) await set.DocumentationSet.ResolveDirectoryTree(ctx); return sources; @@ -70,21 +89,21 @@ private AssembleSources( AssembleContext assembleContext, Checkout[] checkouts, IConfigurationContext configurationContext, + FrozenDictionary navigationTocMappings, + FrozenDictionary> legacyUrlMappings, + PublishEnvironmentUriResolver uriResolver, + ICrossLinkResolver crossLinkResolver, IReadOnlySet availableExporters ) { + NavigationTocMappings = navigationTocMappings; + LegacyUrlMappings = legacyUrlMappings; + UriResolver = uriResolver; AssembleContext = assembleContext; - NavigationTocMappings = GetTocMappings(assembleContext); - HistoryMappings = GetLegacyUrlMappings(assembleContext); - var linkIndexProvider = Aws3LinkIndexReader.CreateAnonymous(); - - var crossLinkFetcher = new AssemblerCrossLinkFetcher(logFactory, assembleContext.Configuration, assembleContext.Environment, linkIndexProvider); - UriResolver = new PublishEnvironmentUriResolver(NavigationTocMappings, assembleContext.Environment); - - var crossLinkResolver = new CrossLinkResolver(crossLinkFetcher, UriResolver); AssembleSets = checkouts .Where(c => c.Repository is { Skip: false }) - .Select(c => new AssemblerDocumentationSet(logFactory, assembleContext, c, crossLinkResolver, TreeCollector, configurationContext, availableExporters)) + .Select(c => new AssemblerDocumentationSet(logFactory, assembleContext, c, crossLinkResolver, TreeCollector, configurationContext, + availableExporters)) .ToDictionary(s => s.Checkout.Repository.Name, s => s) .ToFrozenDictionary(); diff --git a/src/tooling/docs-assembler/Building/AssemblerBuilder.cs b/src/tooling/docs-assembler/Building/AssemblerBuilder.cs index 083d11a35..b0771bd16 100644 --- a/src/tooling/docs-assembler/Building/AssemblerBuilder.cs +++ b/src/tooling/docs-assembler/Building/AssemblerBuilder.cs @@ -58,7 +58,7 @@ public async Task BuildAllAsync(FrozenDictionary public class AssemblerCrossLinkFetcher(ILoggerFactory logFactory, AssemblyConfiguration configuration, PublishEnvironment publishEnvironment, ILinkIndexReader linkIndexProvider) : CrossLinkFetcher(logFactory, linkIndexProvider) { - public override async Task Fetch(Cancel ctx) + public override async Task FetchCrossLinks(Cancel ctx) { + Logger.LogInformation("Fetching cross-links for all repositories defined in assembler.yml"); var linkReferences = new Dictionary(); var linkIndexEntries = new Dictionary(); var declaredRepositories = new HashSet(); @@ -38,7 +40,7 @@ public override async Task Fetch(Cancel ctx) var branch = repository.GetBranch(publishEnvironment.ContentSource); - var linkReference = await Fetch(repositoryName, [branch], ctx); + var linkReference = await FetchCrossLinks(repositoryName, [branch], ctx); linkReferences.Add(repositoryName, linkReference); var linkIndexReference = await GetLinkIndexEntry(repositoryName, ctx); linkIndexEntries.Add(repositoryName, linkIndexReference); @@ -49,7 +51,6 @@ public override async Task Fetch(Cancel ctx) DeclaredRepositories = declaredRepositories, LinkIndexEntries = linkIndexEntries.ToFrozenDictionary(), LinkReferences = linkReferences.ToFrozenDictionary(), - FromConfiguration = false }; } } diff --git a/src/tooling/docs-assembler/Building/SitemapBuilder.cs b/src/tooling/docs-assembler/Building/SitemapBuilder.cs index 13423b1c0..508e95175 100644 --- a/src/tooling/docs-assembler/Building/SitemapBuilder.cs +++ b/src/tooling/docs-assembler/Building/SitemapBuilder.cs @@ -38,7 +38,7 @@ public void Generate() { DocumentationGroup group => (group.Index.Url, NavigationItem: group), FileNavigationItem file => (file.Model.Url, NavigationItem: file as INavigationItem), - _ => throw new Exception($"Unhandled navigation item type: {n.GetType()}") + _ => throw new Exception($"{nameof(SitemapBuilder)}.{nameof(Generate)}: Unhandled navigation item type: {n.GetType()}") }) .Select(n => n.Url) .Distinct() @@ -79,7 +79,7 @@ private static IReadOnlyCollection GetNavigationItems(IReadOnly case CrossLinkNavigationItem: continue; // we do not emit cross links in the sitemap default: - throw new Exception($"Unhandled navigation item type: {item.GetType()}"); + throw new Exception($"{nameof(SitemapBuilder)}.{nameof(GetNavigationItems)}: Unhandled navigation item type: {item.GetType()}"); } } diff --git a/src/tooling/docs-assembler/Cli/ContentSourceCommands.cs b/src/tooling/docs-assembler/Cli/ContentSourceCommands.cs index 29293af0c..3df93f9a2 100644 --- a/src/tooling/docs-assembler/Cli/ContentSourceCommands.cs +++ b/src/tooling/docs-assembler/Cli/ContentSourceCommands.cs @@ -36,7 +36,7 @@ public async Task Validate(Cancel ctx = default) var context = new AssembleContext(configuration, configurationContext, "dev", collector, fs, fs, null, null); ILinkIndexReader linkIndexReader = Aws3LinkIndexReader.CreateAnonymous(); var fetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexReader); - var links = await fetcher.FetchLinkIndex(ctx); + var links = await fetcher.FetchLinkRegistry(ctx); var repositories = context.Configuration.AvailableRepositories; var reportPath = context.ConfigurationFileProvider.AssemblerFile; diff --git a/src/tooling/docs-assembler/Cli/RepositoryCommands.cs b/src/tooling/docs-assembler/Cli/RepositoryCommands.cs index 1ac7d4726..6ccbbd6cf 100644 --- a/src/tooling/docs-assembler/Cli/RepositoryCommands.cs +++ b/src/tooling/docs-assembler/Cli/RepositoryCommands.cs @@ -21,6 +21,7 @@ using Elastic.Documentation.Tooling.Diagnostics.Console; using Elastic.Markdown; using Elastic.Markdown.IO; +using Elastic.Markdown.Links.CrossLinks; using Microsoft.Extensions.Logging; namespace Documentation.Assembler.Cli; @@ -180,7 +181,7 @@ public async Task BuildAll( var pathProvider = new GlobalNavigationPathProvider(navigationFile, assembleSources, assembleContext); var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector); var legacyPageChecker = new LegacyPageChecker(); - var historyMapper = new PageLegacyUrlMapper(legacyPageChecker, assembleSources.HistoryMappings); + var historyMapper = new PageLegacyUrlMapper(legacyPageChecker, assembleSources.LegacyUrlMappings); var builder = new AssemblerBuilder(logFactory, assembleContext, navigation, htmlWriter, pathProvider, historyMapper); await builder.BuildAllAsync(assembleSources.AssembleSets, exporters, ctx); @@ -242,8 +243,11 @@ await Parallel.ForEachAsync(repositories, checkout.Directory.FullName, outputPath ); - var set = new DocumentationSet(context, logFactory); - var generator = new DocumentationGenerator(set, logFactory, null, null, null); + var crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(logFactory, context.Configuration); + var crossLinks = await crossLinkFetcher.FetchCrossLinks(c); + var crossLinkResolver = new CrossLinkResolver(crossLinks); + var set = new DocumentationSet(context, logFactory, crossLinkResolver); + var generator = new DocumentationGenerator(set, logFactory); _ = await generator.GenerateAll(c); IAmazonS3 s3Client = new AmazonS3Client(); diff --git a/src/tooling/docs-assembler/Links/NavigationPrefixChecker.cs b/src/tooling/docs-assembler/Links/NavigationPrefixChecker.cs index 14141aa93..2f5173381 100644 --- a/src/tooling/docs-assembler/Links/NavigationPrefixChecker.cs +++ b/src/tooling/docs-assembler/Links/NavigationPrefixChecker.cs @@ -83,11 +83,11 @@ private async Task FetchAndValidateCrossLinks(IDiagnosticsCollector collector, s { var linkIndexProvider = Aws3LinkIndexReader.CreateAnonymous(); var fetcher = new LinksIndexCrossLinkFetcher(_logFactoryFactory, linkIndexProvider); - var resolver = new CrossLinkResolver(fetcher); - var crossLinks = await resolver.FetchLinks(ctx); + var crossLinks = await fetcher.FetchCrossLinks(ctx); + var crossLinkResolver = new CrossLinkResolver(crossLinks); var dictionary = new Dictionary(); if (!string.IsNullOrEmpty(updateRepository) && updateReference is not null) - crossLinks = resolver.UpdateLinkReference(updateRepository, updateReference); + crossLinks = crossLinkResolver.UpdateLinkReference(updateRepository, updateReference); foreach (var (repository, linkReference) in crossLinks.LinkReferences) { if (!_repositories.Contains(repository)) diff --git a/src/tooling/docs-assembler/Navigation/AssemblerDocumentationSet.cs b/src/tooling/docs-assembler/Navigation/AssemblerDocumentationSet.cs index 8968a06c9..0219a1cdb 100644 --- a/src/tooling/docs-assembler/Navigation/AssemblerDocumentationSet.cs +++ b/src/tooling/docs-assembler/Navigation/AssemblerDocumentationSet.cs @@ -27,7 +27,7 @@ public AssemblerDocumentationSet( ILoggerFactory logFactory, AssembleContext context, Checkout checkout, - CrossLinkResolver crossLinkResolver, + ICrossLinkResolver crossLinkResolver, TableOfContentsTreeCollector treeCollector, IConfigurationContext configurationContext, IReadOnlySet availableExporters diff --git a/src/tooling/docs-assembler/Navigation/GlobalNavigation.cs b/src/tooling/docs-assembler/Navigation/GlobalNavigation.cs index 82e98557d..ac3669ffa 100644 --- a/src/tooling/docs-assembler/Navigation/GlobalNavigation.cs +++ b/src/tooling/docs-assembler/Navigation/GlobalNavigation.cs @@ -81,6 +81,11 @@ private void UpdateParent( fileNavigationItem.Model.NavigationRoot = topLevelNavigation; _ = allNavigationItems.Add(fileNavigationItem); break; + case CrossLinkNavigationItem crossLinkNavigationItem: + if (parent is not null) + crossLinkNavigationItem.Parent = parent; + _ = allNavigationItems.Add(crossLinkNavigationItem); + break; case DocumentationGroup documentationGroup: if (parent is not null) documentationGroup.Parent = parent; @@ -90,7 +95,7 @@ private void UpdateParent( UpdateParent(allNavigationItems, documentationGroup.NavigationItems, documentationGroup, topLevelNavigation); break; default: - _navigationFile.EmitError($"Unhandled navigation item type: {item.GetType()}"); + _navigationFile.EmitError($"{nameof(GlobalNavigation)}.{nameof(UpdateParent)}: Unhandled navigation item type: {item.GetType()}"); break; } } @@ -112,8 +117,12 @@ private void UpdateNavigationIndex(IReadOnlyCollection navigati documentationGroup.NavigationIndex = groupIndex; UpdateNavigationIndex(documentationGroup.NavigationItems, ref navigationIndex); break; + case CrossLinkNavigationItem crossLinkNavigationItem: + var crossLinkIndex = Interlocked.Increment(ref navigationIndex); + crossLinkNavigationItem.NavigationIndex = crossLinkIndex; + break; default: - _navigationFile.EmitError($"Unhandled navigation item type: {item.GetType()}"); + _navigationFile.EmitError($"{nameof(GlobalNavigation)}.{nameof(UpdateNavigationIndex)}: Unhandled navigation item type: {item.GetType()}"); break; } } diff --git a/src/tooling/docs-assembler/Navigation/GlobalNavigationFile.cs b/src/tooling/docs-assembler/Navigation/GlobalNavigationFile.cs index b730e0296..2777be7f1 100644 --- a/src/tooling/docs-assembler/Navigation/GlobalNavigationFile.cs +++ b/src/tooling/docs-assembler/Navigation/GlobalNavigationFile.cs @@ -148,7 +148,7 @@ public void EmitWarning(string message) => _context.Collector.EmitWarning(NavigationFile, message); public void EmitError(string message) => - _context.Collector.EmitWarning(NavigationFile, message); + _context.Collector.EmitError(NavigationFile, message); private IReadOnlyCollection Deserialize(string key) { diff --git a/src/tooling/docs-builder/Cli/Commands.cs b/src/tooling/docs-builder/Cli/Commands.cs index 88d4606c3..8ef651223 100644 --- a/src/tooling/docs-builder/Cli/Commands.cs +++ b/src/tooling/docs-builder/Cli/Commands.cs @@ -15,6 +15,7 @@ using Elastic.Documentation.Tooling.Diagnostics.Console; using Elastic.Markdown; using Elastic.Markdown.IO; +using Elastic.Markdown.Links.CrossLinks; using Microsoft.Extensions.Logging; namespace Documentation.Builder.Cli; @@ -147,8 +148,12 @@ public async Task Generate( if (runningOnCi) await githubActionsService.SetOutputAsync("skip", "false"); + var crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(logFactory, context.Configuration); + var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx); + var crossLinkResolver = new CrossLinkResolver(crossLinks); + // always delete output folder on CI - var set = new DocumentationSet(context, logFactory); + var set = new DocumentationSet(context, logFactory, crossLinkResolver); if (runningOnCi) set.ClearOutputDirectory(); @@ -223,7 +228,8 @@ public async Task Move( var fileSystem = new FileSystem(); await using var collector = new ConsoleDiagnosticsCollector(logFactory, null).StartAsync(ctx); var context = new BuildContext(collector, fileSystem, fileSystem, configurationContext, ExportOptions.MetadataOnly, path, null); - var set = new DocumentationSet(context, logFactory); + + var set = new DocumentationSet(context, logFactory, NoopCrossLinkResolver.Instance); var moveCommand = new Move(logFactory, fileSystem, fileSystem, set); var result = await moveCommand.Execute(source, target, dryRun ?? false, ctx); diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 96505615d..5466ee6d2 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -58,7 +58,7 @@ IConfigurationContext configurationContext _hostedService = collector; Context = new BuildContext(collector, readFs, writeFs, configurationContext, ExportOptions.Default, path, null) { - CanonicalBaseUrl = new Uri(hostUrl), + CanonicalBaseUrl = new Uri(hostUrl) }; GeneratorState = new ReloadableGeneratorState(logFactory, Context.DocumentationSourceDirectory, Context.OutputDirectory, Context); _ = builder.Services @@ -68,7 +68,7 @@ IConfigurationContext configurationContext s.ClientFileExtensions = ".md,.yml"; }) .AddSingleton(_ => GeneratorState) - .AddHostedService(); + .AddHostedService((_) => new ReloadGeneratorService(GeneratorState, logFactory.CreateLogger())); if (IsDotNetWatchBuild()) _ = builder.Services.AddHostedService(); diff --git a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs index 66235b18e..d1d2d20fa 100644 --- a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs +++ b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs @@ -20,10 +20,8 @@ public static void UpdateApplication(Type[]? _) => Task.Run(async () => var __ = LiveReloadMiddleware.RefreshWebSocketRequest(); Console.WriteLine("UpdateApplication"); }); - } - public sealed class ReloadGeneratorService(ReloadableGeneratorState reloadableGenerator, ILogger logger) : IHostedService, IDisposable { private FileSystemWatcher? _watcher; diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 548b7f401..ba955597b 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -8,37 +8,54 @@ using Elastic.Markdown; using Elastic.Markdown.Exporters; using Elastic.Markdown.IO; +using Elastic.Markdown.Links.CrossLinks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Documentation.Builder.Http; /// Singleton behavior enforced by registration on -public class ReloadableGeneratorState( - ILoggerFactory logFactory, - IDirectoryInfo sourcePath, - IDirectoryInfo outputPath, - BuildContext context) +public class ReloadableGeneratorState : IDisposable { - private IDirectoryInfo SourcePath { get; } = sourcePath; - private IDirectoryInfo OutputPath { get; } = outputPath; - public IDirectoryInfo ApiPath { get; } = context.WriteFileSystem.DirectoryInfo.New(Path.Combine(outputPath.FullName, "api")); + private IDirectoryInfo SourcePath { get; } + private IDirectoryInfo OutputPath { get; } + public IDirectoryInfo ApiPath { get; } + + private DocumentationGenerator _generator; + private readonly ILoggerFactory _logFactory; + private readonly BuildContext _context; + private readonly DocSetConfigurationCrossLinkFetcher _crossLinkFetcher; + + public ReloadableGeneratorState(ILoggerFactory logFactory, + IDirectoryInfo sourcePath, + IDirectoryInfo outputPath, + BuildContext context) + { + _logFactory = logFactory; + _context = context; + SourcePath = sourcePath; + OutputPath = outputPath; + ApiPath = context.WriteFileSystem.DirectoryInfo.New(Path.Combine(outputPath.FullName, "api")); + _crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(logFactory, _context.Configuration); + // we pass NoopCrossLinkResolver.Instance here because `ReloadAsync` will always be called when the is started. + _generator = new DocumentationGenerator(new DocumentationSet(context, logFactory, NoopCrossLinkResolver.Instance), logFactory); + } - private DocumentationGenerator _generator = new(new DocumentationSet(context, logFactory), logFactory); public DocumentationGenerator Generator => _generator; public async Task ReloadAsync(Cancel ctx) { SourcePath.Refresh(); OutputPath.Refresh(); - var docSet = new DocumentationSet(context, logFactory); - _ = await docSet.LinkResolver.FetchLinks(ctx); + var crossLinks = await _crossLinkFetcher.FetchCrossLinks(ctx); + var crossLinkResolver = new CrossLinkResolver(crossLinks); + var docSet = new DocumentationSet(_context, _logFactory, crossLinkResolver); // Add LLM markdown export for dev server var markdownExporters = new List(); markdownExporters.AddLlmMarkdownExport(); // Consistent LLM-optimized output - var generator = new DocumentationGenerator(docSet, logFactory, markdownExporters: markdownExporters.ToArray()); + var generator = new DocumentationGenerator(docSet, _logFactory, markdownExporters: markdownExporters.ToArray()); await generator.ResolveDirectoryTree(ctx); _ = Interlocked.Exchange(ref _generator, generator); @@ -52,7 +69,13 @@ private async Task ReloadApiReferences(IMarkdownStringRenderer markdownStringRen if (ApiPath.Exists) ApiPath.Delete(true); ApiPath.Create(); - var generator = new OpenApiGenerator(logFactory, context, markdownStringRenderer); + var generator = new OpenApiGenerator(_logFactory, _context, markdownStringRenderer); await generator.Generate(ctx); } + + public void Dispose() + { + _crossLinkFetcher.Dispose(); + GC.SuppressFinalize(this); + } } diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index caf2bde88..788afe3d1 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -83,7 +83,6 @@ public virtual async ValueTask InitializeAsync() { _ = Collector.StartAsync(TestContext.Current.CancellationToken); - await Set.LinkResolver.FetchLinks(TestContext.Current.CancellationToken); Document = await File.ParseFullAsync(TestContext.Current.CancellationToken); var html = MarkdownFile.CreateHtml(Document).AsSpan(); var find = ""; diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index 321cf05e1..936cf66de 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -131,7 +131,6 @@ public virtual async ValueTask InitializeAsync() _ = Collector.StartAsync(TestContext.Current.CancellationToken); await Set.ResolveDirectoryTree(TestContext.Current.CancellationToken); - await Set.LinkResolver.FetchLinks(TestContext.Current.CancellationToken); Document = await File.ParseFullAsync(TestContext.Current.CancellationToken); var html = MarkdownFile.CreateHtml(Document).AsSpan(); diff --git a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs index aa8f5e838..00ae80d00 100644 --- a/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs +++ b/tests/Elastic.Markdown.Tests/TestCrossLinkResolver.cs @@ -12,17 +12,12 @@ namespace Elastic.Markdown.Tests; public class TestCrossLinkResolver : ICrossLinkResolver { + private readonly FetchedCrossLinks _crossLinks; + public IUriEnvironmentResolver UriResolver { get; } = new IsolatedBuildEnvironmentUriResolver(); - private FetchedCrossLinks _crossLinks = FetchedCrossLinks.Empty; - private Dictionary LinkReferences { get; } = []; - private HashSet DeclaredRepositories { get; } = []; - public Task FetchLinks(Cancel ctx) + public TestCrossLinkResolver() { - // Clear existing entries to prevent duplicate key errors when called multiple times - LinkReferences.Clear(); - DeclaredRepositories.Clear(); - // language=json var json = """ { @@ -49,26 +44,26 @@ public Task FetchLinks(Cancel ctx) } """; var reference = CrossLinkFetcher.Deserialize(json); - LinkReferences.Add("docs-content", reference); - LinkReferences.Add("kibana", reference); - DeclaredRepositories.AddRange(["docs-content", "kibana"]); + var linkReferences = new Dictionary(); + var declaredRepositories = new HashSet(); + linkReferences.Add("docs-content", reference); + linkReferences.Add("kibana", reference); + declaredRepositories.AddRange(["docs-content", "kibana"]); - var indexEntries = LinkReferences.ToDictionary(e => e.Key, e => new LinkRegistryEntry + var indexEntries = linkReferences.ToDictionary(e => e.Key, e => new LinkRegistryEntry { Repository = e.Key, - Path = $"elastic/asciidocalypse/{e.Key}/links.json", + Path = $"elastic/docs-builder-tests/{e.Key}/links.json", Branch = "main", ETag = Guid.NewGuid().ToString(), GitReference = Guid.NewGuid().ToString() }); _crossLinks = new FetchedCrossLinks { - DeclaredRepositories = DeclaredRepositories, - LinkReferences = LinkReferences.ToFrozenDictionary(), - FromConfiguration = true, + DeclaredRepositories = declaredRepositories, + LinkReferences = linkReferences.ToFrozenDictionary(), LinkIndexEntries = indexEntries.ToFrozenDictionary() }; - return Task.FromResult(_crossLinks); } public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs index 0f1acd54e..3f11aee02 100644 --- a/tests/authoring/Framework/CrossLinkResolverAssertions.fs +++ b/tests/authoring/Framework/CrossLinkResolverAssertions.fs @@ -60,7 +60,6 @@ module CrossLinkResolverAssertions = FetchedCrossLinks( DeclaredRepositories = declaredRepos, LinkReferences = FrozenDictionary.ToFrozenDictionary(dict [repoName, repositoryLinks]), - FromConfiguration = true, LinkIndexEntries = FrozenDictionary.Empty ) diff --git a/tests/authoring/Framework/TestCrossLinkResolver.fs b/tests/authoring/Framework/TestCrossLinkResolver.fs index e9c7e4679..2691bca69 100644 --- a/tests/authoring/Framework/TestCrossLinkResolver.fs +++ b/tests/authoring/Framework/TestCrossLinkResolver.fs @@ -8,7 +8,6 @@ open System open System.Collections.Generic open System.Collections.Frozen open System.Runtime.InteropServices -open System.Threading.Tasks open System.Linq open Elastic.Documentation.Configuration.Builder open Elastic.Documentation.Links @@ -19,22 +18,12 @@ type TestCrossLinkResolver (config: ConfigurationFile) = let references = Dictionary() let declared = HashSet() let uriResolver = IsolatedBuildEnvironmentUriResolver() - - member this.LinkReferences = references - member this.DeclaredRepositories = declared - - interface ICrossLinkResolver with - - 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 = $$"""{ + let mutable crossLinks = FetchedCrossLinks.Empty + + do + let redirects = RepositoryLinks.SerializeRedirects config.Redirects + // language=json + let json = $$"""{ "origin": { "branch": "main", "remote": " https://github.com/elastic/docs-content", @@ -65,48 +54,39 @@ type TestCrossLinkResolver (config: ConfigurationFile) = } } """ - let reference = CrossLinkFetcher.Deserialize json - this.LinkReferences.Add("docs-content", reference) - this.LinkReferences.Add("kibana", reference) - this.DeclaredRepositories.Add("docs-content") |> ignore; - this.DeclaredRepositories.Add("kibana") |> ignore; + let reference = CrossLinkFetcher.Deserialize json + references.Add("docs-content", reference) + references.Add("kibana", reference) + declared.Add("docs-content") |> ignore; + declared.Add("kibana") |> ignore; - let indexEntries = - this.LinkReferences.ToDictionary(_.Key, fun (e : KeyValuePair) -> LinkRegistryEntry( - Repository = e.Key, - Path = $"elastic/asciidocalypse/{e.Key}/links.json", - Branch = "main", - ETag = Guid.NewGuid().ToString(), - GitReference = Guid.NewGuid().ToString() - )) + let indexEntries = + references.ToDictionary(_.Key, fun (e : KeyValuePair) -> LinkRegistryEntry( + Repository = e.Key, + Path = $"elastic/docs-builder-tests/{e.Key}/links.json", + Branch = "main", + ETag = Guid.NewGuid().ToString(), + GitReference = Guid.NewGuid().ToString() + )) - let crossLinks = - FetchedCrossLinks( - DeclaredRepositories=this.DeclaredRepositories, - LinkReferences=this.LinkReferences.ToFrozenDictionary(), - FromConfiguration=true, - LinkIndexEntries=indexEntries.ToFrozenDictionary() - ) - Task.FromResult crossLinks + let resolvedCrossLinks = + FetchedCrossLinks( + DeclaredRepositories=declared, + LinkReferences=references.ToFrozenDictionary(), + LinkIndexEntries=indexEntries.ToFrozenDictionary() + ) + crossLinks <- resolvedCrossLinks - member this.TryResolve(errorEmitter, crossLinkUri, []resolvedUri : byref) = - let indexEntries = - this.LinkReferences.ToDictionary(_.Key, fun (e : KeyValuePair) -> LinkRegistryEntry( - Repository = e.Key, - Path = $"elastic/asciidocalypse/{e.Key}/links.json", - Branch = "main", - ETag = Guid.NewGuid().ToString(), - GitReference = Guid.NewGuid().ToString() - )); + - let crossLinks = - FetchedCrossLinks( - DeclaredRepositories=this.DeclaredRepositories, - LinkReferences=this.LinkReferences.ToFrozenDictionary(), - FromConfiguration=true, - LinkIndexEntries=indexEntries.ToFrozenDictionary() + member this.LinkReferences = references + member this.DeclaredRepositories = declared + + interface ICrossLinkResolver with - ) - CrossLinkResolver.TryResolve(errorEmitter, crossLinks, uriResolver, crossLinkUri, &resolvedUri); + member this.UriResolver = uriResolver + + member this.TryResolve(errorEmitter, crossLinkUri, []resolvedUri : byref) = + CrossLinkResolver.TryResolve(errorEmitter, crossLinks, uriResolver, crossLinkUri, &resolvedUri)