diff --git a/docs/configure/content-set/navigation.md b/docs/configure/content-set/navigation.md index e39933e732..c8a56a1268 100644 --- a/docs/configure/content-set/navigation.md +++ b/docs/configure/content-set/navigation.md @@ -201,6 +201,30 @@ does not match the directory structure. A `file` can only select siblings and more deeply nested files as its children. It can't select files outside its own subtree on disk. +#### Deep-linked `index.md` files + +A `file` entry without `children` that points at an `index.md` inside a subdirectory is treated as a single-page subsection for that directory: + +```yaml +toc: + - file: reference/1password/index.md + - file: reference/activemq/index.md +``` + +Each entry becomes its own subsection linking to the directory's `index.md`. This is convenient when every subdirectory contains only an `index.md`. It is shorthand for: + +```yaml +toc: + - folder: reference/1password + children: + - file: index.md + - folder: reference/activemq + children: + - file: index.md +``` + +If the entry declares `children`, it keeps the [virtual grouping](#virtual-grouping) (deep-linking) behavior instead. + #### Hidden files A hidden file can be declared in the TOC. diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs index 664c18085b..ddacc423b2 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs @@ -152,9 +152,22 @@ public class TocItemYamlConverter : IYamlTypeConverter // PathRelativeToContainer will be set during resolution if (dictionary.TryGetValue("file", out var filePathOnly) && filePathOnly is string fileOnly) { - return fileOnly == "index.md" - ? new IndexFileRef(fileOnly, fileOnly, false, children, placeholderContext) - : new FileRef(fileOnly, fileOnly, false, children, placeholderContext); + if (fileOnly == "index.md") + return new IndexFileRef(fileOnly, fileOnly, false, children, placeholderContext); + + // A childless deep-linked index file (e.g. "file: reference/1password/index.md") is treated as + // sugar for "folder: reference/1password, file: index.md". Otherwise it resolves to a bare leaf + // that competes with its siblings for the parent's index slot and gets silently dropped from the + // navigation. Entries that declare explicit children keep the existing virtual-file (deep-linking) + // semantics, so this remains backward compatible. + if (children.Count == 0 && fileOnly.EndsWith("/index.md", StringComparison.Ordinal)) + { + var indexFolderPath = fileOnly[..^"/index.md".Length]; + var indexFile = new FolderIndexFileRef("index.md", "index.md", false, [], placeholderContext); + return new FolderRef(indexFolderPath, indexFolderPath, [indexFile], placeholderContext); + } + + return new FileRef(fileOnly, fileOnly, false, children, placeholderContext); } if (dictionary.TryGetValue("hidden", out var hiddenPath) && hiddenPath is string p) diff --git a/tests/Navigation.Tests/Isolation/DeepLinkedIndexFileTests.cs b/tests/Navigation.Tests/Isolation/DeepLinkedIndexFileTests.cs new file mode 100644 index 0000000000..f7837b7702 --- /dev/null +++ b/tests/Navigation.Tests/Isolation/DeepLinkedIndexFileTests.cs @@ -0,0 +1,185 @@ +// 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.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Leaf; +using Elastic.Documentation.Navigation.Isolated.Node; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +/// +/// Regression coverage for https://github.com/elastic/docs-builder/issues/764. +/// A childless file: subdir/index.md entry is sugar for folder: subdir, file: index.md, +/// so each entry becomes its own subfolder with a proper index page instead of competing for the +/// parent's index slot (which silently dropped it from the navigation). +/// +public class DeepLinkedIndexFileTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void ChildlessDeepLinkedIndexFileBecomesFolderRef() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: reference/1password/index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var folder = docSet.TableOfContents.First().Should().BeOfType().Subject; + folder.PathRelativeToDocumentationSet.Should().Be("reference/1password"); + + var index = folder.Children.First().Should().BeOfType().Subject; + index.PathRelativeToDocumentationSet.Should().Be("reference/1password/index.md"); + } + + [Fact] + public void BareIndexFileIsNotConverted() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - file: other.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + // Bare index.md stays on the IndexFileRef path, never wrapped into a folder. + _ = docSet.TableOfContents.First().Should().BeOfType(); + docSet.TableOfContents.OfType().Should().BeEmpty(); + } + + [Fact] + public void DeepLinkedIndexFileWithChildrenStaysVirtualFile() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: reference/aws/index.md + children: + - file: cloudfront.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + // Entries that declare explicit children keep the existing virtual-file (deep-linking) semantics. + var fileRef = docSet.TableOfContents.First().Should().BeOfType().Subject; + fileRef.Should().NotBeOfType(); + fileRef.PathRelativeToDocumentationSet.Should().Be("reference/aws/index.md"); + fileRef.Children.Should().ContainSingle(); + } + + [Fact] + public async Task ChildlessDeepLinkedIndexFileRendersAsSingleLinkFolder() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - file: reference/1password/index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + var folder = navigation.NavigationItems.OfType>().Single(); + folder.Url.Should().Be("/reference/1password"); + // No other navigation items, so the frontend renders this as a plain link rather than an expandable folder. + folder.NavigationItems.Should().BeEmpty(); + context.Collector.Errors.Should().Be(0); + } + + [Fact] + public async Task FolderWithChildlessIndexFileChildrenKeepsEveryEntry() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: reference + file: index.md + children: + - file: 1password/index.md + - file: abnormal_security/index.md + - file: activemq/index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + var reference = navigation.NavigationItems.OfType>().Single(); + reference.Url.Should().Be("/reference"); + + // All three entries survive as their own subfolders; none is consumed as the parent's index. + var childFolders = reference.NavigationItems.OfType>().ToList(); + childFolders.Select(f => f.Url).Should().BeEquivalentTo( + ["/reference/1password", "/reference/abnormal_security", "/reference/activemq"]); + childFolders.Should().AllSatisfy(f => f.NavigationItems.Should().BeEmpty()); + context.Collector.Errors.Should().Be(0); + } + + [Fact] + public async Task ChildlessIndexFileChildrenUnderVirtualFileResolveWithoutPathDoubling() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: reference/apache-intro.md + children: + - file: reference/apache/index.md + - file: reference/apache_spark/index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + var intro = navigation.NavigationItems.OfType>().Single(); + intro.Url.Should().Be("/reference/apache-intro"); + + var childFolders = intro.NavigationItems.OfType>().ToList(); + // Deep-linked index paths resolve relative to the documentation set root, not the parent path. + childFolders.Select(f => f.Url).Should().BeEquivalentTo(["/reference/apache", "/reference/apache_spark"]); + context.Collector.Errors.Should().Be(0); + } +}