Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/configure/content-set/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
185 changes: 185 additions & 0 deletions tests/Navigation.Tests/Isolation/DeepLinkedIndexFileTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Regression coverage for https://github.com/elastic/docs-builder/issues/764.
/// A childless <c>file: subdir/index.md</c> entry is sugar for <c>folder: subdir, file: index.md</c>,
/// 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).
/// </summary>
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<FolderRef>().Subject;
folder.PathRelativeToDocumentationSet.Should().Be("reference/1password");

var index = folder.Children.First().Should().BeOfType<FolderIndexFileRef>().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<IndexFileRef>();
docSet.TableOfContents.OfType<FolderRef>().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<FileRef>().Subject;
fileRef.Should().NotBeOfType<FolderRef>();
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<IDocumentationFile>(docSet, context, GenericDocumentationFileFactory.Instance);

await context.Collector.StopAsync(TestContext.Current.CancellationToken);

var folder = navigation.NavigationItems.OfType<FolderNavigation<IDocumentationFile>>().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<IDocumentationFile>(docSet, context, GenericDocumentationFileFactory.Instance);

await context.Collector.StopAsync(TestContext.Current.CancellationToken);

var reference = navigation.NavigationItems.OfType<FolderNavigation<IDocumentationFile>>().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<FolderNavigation<IDocumentationFile>>().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<IDocumentationFile>(docSet, context, GenericDocumentationFileFactory.Instance);

await context.Collector.StopAsync(TestContext.Current.CancellationToken);

var intro = navigation.NavigationItems.OfType<VirtualFileNavigation<IDocumentationFile>>().Single();
intro.Url.Should().Be("/reference/apache-intro");

var childFolders = intro.NavigationItems.OfType<FolderNavigation<IDocumentationFile>>().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);
}
}
Loading