From 994339d2dcc9e0138e768681bbb016943076e7dc Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 25 Jul 2025 17:09:59 +0200 Subject: [PATCH 1/7] Add links to tocs --- docs/configure/content-set/navigation.md | 33 +++++++++++++++++++ .../Builder/TableOfContentsConfiguration.cs | 17 ++++++++++ .../TableOfContents/ITocItem.cs | 16 ++++----- .../IO/Navigation/DocumentationGroup.cs | 6 ++++ .../Navigation/ExternalLinkNavigationItem.cs | 26 +++++++++++++++ 5 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 src/Elastic.Markdown/IO/Navigation/ExternalLinkNavigationItem.cs diff --git a/docs/configure/content-set/navigation.md b/docs/configure/content-set/navigation.md index 318a32804..2f70da400 100644 --- a/docs/configure/content-set/navigation.md +++ b/docs/configure/content-set/navigation.md @@ -148,6 +148,39 @@ It [may be linked to locally however](../../developer-notes.md) #### Nesting `toc` +The `toc` key can include nested `toc.yml` files. + +The following example includes two sub-`toc.yml` files located in directories named `elastic-basics` and `solutions`: + +```yml +toc: + - file: index.md + - toc: elastic-basics + - toc: solutions +``` + +#### External links + +You can include links to external websites directly in your navigation tree. Use the `link` key with a valid URL, and provide a human-friendly `title`. + +```yaml +toc: + - link: https://elastic.co + title: Elastic Website + - toc: getting-started + - link: https://github.com/elastic/docs-builder + title: Docs Builder Repository +``` + +Docs-builder renders external links with an icon, opens them in a new tab, and adds `rel="noopener noreferrer"` for security. + +Best practices: + +* Use external links sparingly in your primary navigation, placing them near the bottom of a section when possible. +* Provide clear titles that indicate the link leads to an external resource. +* Periodically verify that external URLs remain valid, and update them if destinations change. + + The `toc` key can include nested `toc.yml` files. The following example includes two sub-`toc.yml` files located in directories named `elastic-basics` and `solutions`: diff --git a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs index 907ee8e6b..3120d1c75 100644 --- a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs @@ -130,6 +130,8 @@ private IReadOnlyCollection ReadChildren(YamlStreamReader reader, KeyV { string? file = null; string? folder = null; + string? link = null; + string? linkTitle = null; string[]? detectionRules = null; TableOfContentsConfiguration? toc = null; var detectionRulesFound = false; @@ -148,6 +150,12 @@ private IReadOnlyCollection ReadChildren(YamlStreamReader reader, KeyV hiddenFile = key == "hidden"; file = ReadFile(reader, entry, parentPath); break; + case "link": + link = reader.ReadString(entry); + break; + case "title": + linkTitle = reader.ReadString(entry); + break; case "folder": folder = ReadFolder(reader, entry, parentPath); parentPath += $"{Path.DirectorySeparatorChar}{folder}"; @@ -199,6 +207,15 @@ private IReadOnlyCollection ReadChildren(YamlStreamReader reader, KeyV return [new FileReference(this, path, hiddenFile, children ?? [])]; } + if (link is not null) + { + // external links cannot have children + if (children is not null) + reader.EmitWarning("'link' entries may not contain 'children'", tocEntry); + + return [new LinkReference(this, link, linkTitle ?? link)]; + } + 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..e4a3d4599 100644 --- a/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs +++ b/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs @@ -27,15 +27,15 @@ public record TocReference(Uri Source, ITableOfContentsScope TableOfContentsScop /// A phantom table of contents is a table of contents that is not rendered in the UI but is used to generate the TOC. /// This should be used sparingly and needs explicit configuration in navigation.yml. /// It's typically used for container TOC that holds various other TOC's where its children are rehomed throughout the navigation. - /// Examples of phantom toc's: - /// - /// - toc: elasticsearch://reference - /// - toc: docs-content:// - /// - /// Because navigation.yml does exhaustive checks to ensure all toc.yml files are referenced, marking these containers as phantoms - /// ensures that these skip validation checks - /// /// public bool IsPhantom { get; init; } } +/// +/// Represents an external link in the table of contents. +/// +/// Scope this link belongs to. +/// Absolute URL of the external resource. +/// Display title for the link. +public record LinkReference(ITableOfContentsScope TableOfContentsScope, string Url, string Title) : ITocItem; + diff --git a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs index 038b857be..e21f4e5d9 100644 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs @@ -6,6 +6,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.TableOfContents; +using Elastic.Markdown.IO.Navigation; // for ExternalLinkNavigationItem using Elastic.Documentation.Extensions; using Elastic.Documentation.Site.Navigation; @@ -178,6 +179,11 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex) if (indexFile != md) AddToNavigationItems(new FileNavigationItem(md, this, file.Hidden), ref fileIndex); } + else if (tocItem is LinkReference extLink) + { + var nav = new ExternalLinkNavigationItem(extLink.Url, extLink.Title, this); + AddToNavigationItems(nav, ref fileIndex); + } else if (tocItem is FolderReference folder) { var children = folder.Children; diff --git a/src/Elastic.Markdown/IO/Navigation/ExternalLinkNavigationItem.cs b/src/Elastic.Markdown/IO/Navigation/ExternalLinkNavigationItem.cs new file mode 100644 index 000000000..5084b15bc --- /dev/null +++ b/src/Elastic.Markdown/IO/Navigation/ExternalLinkNavigationItem.cs @@ -0,0 +1,26 @@ +// 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 Elastic.Documentation.Site.Navigation; + +namespace Elastic.Markdown.IO.Navigation; + +/// +/// Navigation item representing an external URL in the TOC. +/// +public record ExternalLinkNavigationItem(string ExternalUrl, string Title, DocumentationGroup Group) + : INavigationItem +{ + public INodeNavigationItem? Parent { get; set; } = Group; + + public IRootNavigationItem NavigationRoot { get; } = Group.NavigationRoot; + + public string Url => ExternalUrl; + + public string NavigationTitle => Title; + + public int NavigationIndex { get; set; } + + public bool Hidden => false; +} From 88a29ab81affebb4349b232148c63db43ddc5478 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 25 Jul 2025 17:13:09 +0200 Subject: [PATCH 2/7] Add sample link --- docs/_docset.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/_docset.yml b/docs/_docset.yml index 58475f7ef..930509308 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -147,3 +147,5 @@ toc: - folder: baz children: - file: qux.md + - link: https://github.com/elastic/docs-builder + title: GitHub repository \ No newline at end of file From 9bdbec0c0e34157f40c6c51b2c0dcdd6afb5d7be Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 25 Jul 2025 17:28:59 +0200 Subject: [PATCH 3/7] It's working --- .../Elastic.Documentation.Site.csproj | 4 ++-- .../Navigation/ExternalLinkNavigationItem.cs | 8 ++++---- .../Navigation/_TocTreeNav.cshtml | 15 +++++++++++++++ src/Elastic.Markdown/IO/DocumentationSet.cs | 5 +++++ .../IO/Navigation/DocumentationGroup.cs | 4 ++-- .../docs-assembler/Building/SitemapBuilder.cs | 3 +++ .../docs-assembler/Navigation/GlobalNavigation.cs | 9 +++++++++ 7 files changed, 40 insertions(+), 8 deletions(-) rename src/{Elastic.Markdown/IO => Elastic.Documentation.Site}/Navigation/ExternalLinkNavigationItem.cs (72%) diff --git a/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj b/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj index c8222af4c..82275cf59 100644 --- a/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj +++ b/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -15,7 +15,7 @@ - + diff --git a/src/Elastic.Markdown/IO/Navigation/ExternalLinkNavigationItem.cs b/src/Elastic.Documentation.Site/Navigation/ExternalLinkNavigationItem.cs similarity index 72% rename from src/Elastic.Markdown/IO/Navigation/ExternalLinkNavigationItem.cs rename to src/Elastic.Documentation.Site/Navigation/ExternalLinkNavigationItem.cs index 5084b15bc..50eff22a9 100644 --- a/src/Elastic.Markdown/IO/Navigation/ExternalLinkNavigationItem.cs +++ b/src/Elastic.Documentation.Site/Navigation/ExternalLinkNavigationItem.cs @@ -4,17 +4,17 @@ using Elastic.Documentation.Site.Navigation; -namespace Elastic.Markdown.IO.Navigation; +namespace Elastic.Documentation.Site.Navigation; /// /// Navigation item representing an external URL in the TOC. /// -public record ExternalLinkNavigationItem(string ExternalUrl, string Title, DocumentationGroup Group) +public record ExternalLinkNavigationItem(string ExternalUrl, string Title, INodeNavigationItem Parent, IRootNavigationItem NavigationRoot) : INavigationItem { - public INodeNavigationItem? Parent { get; set; } = Group; + public INodeNavigationItem? Parent { get; set; } = Parent; - public IRootNavigationItem NavigationRoot { get; } = Group.NavigationRoot; + public IRootNavigationItem NavigationRoot { get; } = NavigationRoot; public string Url => ExternalUrl; diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index 19fe3e250..5709077a5 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -1,4 +1,5 @@ @using Elastic.Documentation.Site.Navigation + @inherits RazorSlice @{ var isTopLevel = Model.Level == 0; @@ -89,4 +90,18 @@ } + else if (item is ExternalLinkNavigationItem ext) + { +
  • + + @ext.NavigationTitle + + +
  • + } } diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 5dc9886c4..0dfeffaac 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -19,6 +19,7 @@ using Elastic.Markdown.Links.CrossLinks; using Elastic.Markdown.Myst; using Microsoft.Extensions.Logging; +using ExternalLinkNavigationItem = Elastic.Documentation.Site.Navigation.ExternalLinkNavigationItem; namespace Elastic.Markdown.IO; @@ -227,6 +228,10 @@ private void UpdateNavigationIndex(IReadOnlyCollection navigati documentationGroup.NavigationIndex = groupIndex; UpdateNavigationIndex(documentationGroup.NavigationItems, ref navigationIndex); break; + case ExternalLinkNavigationItem ext: + var linkIndex = Interlocked.Increment(ref navigationIndex); + ext.NavigationIndex = linkIndex; + break; default: Context.EmitError(Context.ConfigurationPath, $"Unhandled navigation item type: {item.GetType()}"); break; diff --git a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs index e21f4e5d9..e53f09632 100644 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs @@ -6,9 +6,9 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.TableOfContents; -using Elastic.Markdown.IO.Navigation; // for ExternalLinkNavigationItem using Elastic.Documentation.Extensions; using Elastic.Documentation.Site.Navigation; +using ExternalLinkNavigationItem = Elastic.Documentation.Site.Navigation.ExternalLinkNavigationItem; namespace Elastic.Markdown.IO.Navigation; @@ -181,7 +181,7 @@ void AddToNavigationItems(INavigationItem item, ref int fileIndex) } else if (tocItem is LinkReference extLink) { - var nav = new ExternalLinkNavigationItem(extLink.Url, extLink.Title, this); + var nav = new ExternalLinkNavigationItem(extLink.Url, extLink.Title, this, rootNavigationItem); AddToNavigationItems(nav, ref fileIndex); } else if (tocItem is FolderReference folder) diff --git a/src/tooling/docs-assembler/Building/SitemapBuilder.cs b/src/tooling/docs-assembler/Building/SitemapBuilder.cs index fe51ba057..41bcfecfe 100644 --- a/src/tooling/docs-assembler/Building/SitemapBuilder.cs +++ b/src/tooling/docs-assembler/Building/SitemapBuilder.cs @@ -76,6 +76,9 @@ private static IReadOnlyCollection GetNavigationItems(IReadOnly result.AddRange(GetNavigationItems(group.NavigationItems)); result.Add(group); break; + case ExternalLinkNavigationItem link: + // skip external links in sitemap + continue; default: throw new Exception($"Unhandled navigation item type: {item.GetType()}"); } diff --git a/src/tooling/docs-assembler/Navigation/GlobalNavigation.cs b/src/tooling/docs-assembler/Navigation/GlobalNavigation.cs index f028616cd..124e5d92b 100644 --- a/src/tooling/docs-assembler/Navigation/GlobalNavigation.cs +++ b/src/tooling/docs-assembler/Navigation/GlobalNavigation.cs @@ -82,6 +82,11 @@ private void UpdateParent( _ = allNavigationItems.Add(documentationGroup); UpdateParent(allNavigationItems, documentationGroup.NavigationItems, documentationGroup); break; + case ExternalLinkNavigationItem extLink: + if (parent is not null) + extLink.Parent = parent; + _ = allNavigationItems.Add(extLink); + break; default: _navigationFile.EmitError($"Unhandled navigation item type: {item.GetType()}"); break; @@ -105,6 +110,10 @@ private void UpdateNavigationIndex(IReadOnlyCollection navigati documentationGroup.NavigationIndex = groupIndex; UpdateNavigationIndex(documentationGroup.NavigationItems, ref navigationIndex); break; + case ExternalLinkNavigationItem extLink: + var linkIndex = Interlocked.Increment(ref navigationIndex); + extLink.NavigationIndex = linkIndex; + break; default: _navigationFile.EmitError($"Unhandled navigation item type: {item.GetType()}"); break; From da29b0a903f1f73a928f507e4b811a441b3ada6b Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 25 Jul 2025 17:35:33 +0200 Subject: [PATCH 4/7] Add CSS --- .../Assets/markdown/typography.css | 6 ++++++ .../Navigation/_TocTreeNav.cshtml | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/markdown/typography.css b/src/Elastic.Documentation.Site/Assets/markdown/typography.css index 947c11e5a..db284c755 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/typography.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/typography.css @@ -48,6 +48,7 @@ line-height: 1.5em; } + a { @apply font-body text-blue-elastic hover:text-blue-elastic-100 underline; @@ -57,3 +58,8 @@ } } } + +#pages-nav a[target="_blank"]:after { + content: url("data:image/svg+xml; utf8, "); + margin-left: 0.5em; +} diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index 5709077a5..129a66cf5 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -61,7 +61,7 @@ // Only render children if we're within the allowed level depth // MaxLevel of -1 means render all levels bool shouldRenderChildren = Model.MaxLevel == -1 || Model.Level < (Model.MaxLevel); -