Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public class FolderNavigation<TModel>(
public int NavigationIndex { get; set; }

/// <inheritdoc />
public string Id { get; } = ShortId.Create(parentPath);
public string Id { get; private set; } = null!;

/// <inheritdoc />
public ILeafNavigationItem<TModel> Index { get; private set; } = null!;
Expand All @@ -50,6 +50,9 @@ internal void SetNavigationItems(IReadOnlyCollection<INavigationItem> navigation
{
var indexNavigation = navigationItems.QueryIndex<TModel>(this, $"{FolderPath}/index.md", out navigationItems);
Index = indexNavigation;
// Include NavigationRoot.Id to ensure uniqueness across docsets in assembler builds
// (URLs alone aren't unique until path prefixes are applied by SiteNavigation)
Id = ShortId.Create(NavigationRoot.Id, Index.Url);
NavigationItems = navigationItems;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ INavigationHomeProvider homeProvider
Parent = parent;
Hidden = false;
IsUsingNavigationDropdown = false;
Id = ShortId.Create(parentPath);
// Id will be set in SetNavigationItems using Index.Url for uniqueness
Id = null!;
ParentPath = parentPath;
PathPrefix = pathPrefix;

Expand Down Expand Up @@ -103,7 +104,8 @@ internal void SetNavigationItems(IReadOnlyCollection<INavigationItem> navigation
{
var indexNavigation = navigationItems.QueryIndex<TModel>(this, $"{ParentPath}/index.md", out navigationItems);
Index = indexNavigation;
Id = ShortId.Create(indexNavigation.Url);
// Include NavigationRoot.Id to ensure uniqueness across docsets in assembler builds
Id = ShortId.Create(NavigationRoot.Id, Index.Url);
NavigationItems = navigationItems;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ public class VirtualFileNavigation<TModel>(TModel model, IFileInfo fileInfo, Vir
public int NavigationIndex { get; set; }

/// <inheritdoc />
public string Id { get; } = ShortId.Create(args.RelativePathToDocumentationSet);
// Include NavigationRoot.Id to ensure uniqueness across docsets in assembler builds
public string Id => ShortId.Create(NavigationRoot.Id, Index.Url);

/// <inheritdoc />
public ILeafNavigationItem<TModel> Index { get; } =
Expand Down
97 changes: 97 additions & 0 deletions tests/Navigation.Tests/Assembler/SiteNavigationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,103 @@ public void SitePrefixAppliedToNavigationItemUrls(string? sitePrefix, string exp
observabilityItem.Url.Should().Be(expectedObservabilityUrl, $"sitePrefix '{sitePrefix}' should result in URL '{expectedObservabilityUrl}'");
}

[Fact]
public void NavigationNodeIdsAreUniqueAcrossDocsets()
{
// This test verifies that navigation node IDs are unique even when
// multiple docsets have folders with the same relative path.
// This is critical because IDs are used as HTML id attributes.

// Create two docsets, each with a "getting-started" folder at the same relative path
var fileSystem = new MockFileSystem();

// Docset 1: product-a with getting-started folder
var productADir = "/checkouts/current/product-a";
// language=yaml
var productADocset = """
project: product-a
toc:
- file: index.md
- folder: getting-started
children:
- file: index.md
- file: quick-start.md
""";
fileSystem.AddFile($"{productADir}/docs/docset.yml", new MockFileData(productADocset));
fileSystem.AddFile($"{productADir}/docs/index.md", new MockFileData("# Product A"));
fileSystem.AddFile($"{productADir}/docs/getting-started/index.md", new MockFileData("# Getting Started A"));
fileSystem.AddFile($"{productADir}/docs/getting-started/quick-start.md", new MockFileData("# Quick Start A"));

// Docset 2: product-b with getting-started folder (same relative path!)
var productBDir = "/checkouts/current/product-b";
// language=yaml
var productBDocset = """
project: product-b
toc:
- file: index.md
- folder: getting-started
children:
- file: index.md
- file: tutorial.md
""";
fileSystem.AddFile($"{productBDir}/docs/docset.yml", new MockFileData(productBDocset));
fileSystem.AddFile($"{productBDir}/docs/index.md", new MockFileData("# Product B"));
fileSystem.AddFile($"{productBDir}/docs/getting-started/index.md", new MockFileData("# Getting Started B"));
fileSystem.AddFile($"{productBDir}/docs/getting-started/tutorial.md", new MockFileData("# Tutorial B"));

// Create navigation for both docsets
var productAContext = SiteNavigationTestFixture.CreateContext(fileSystem, productADir, output);
var productADocsetFile = DocumentationSetFile.LoadAndResolve(productAContext.Collector, fileSystem.FileInfo.New($"{productADir}/docs/docset.yml"), fileSystem);
var productANav = new DocumentationSetNavigation<IDocumentationFile>(productADocsetFile, productAContext, GenericDocumentationFileFactory.Instance);

var productBContext = SiteNavigationTestFixture.CreateContext(fileSystem, productBDir, output);
var productBDocsetFile = DocumentationSetFile.LoadAndResolve(productBContext.Collector, fileSystem.FileInfo.New($"{productBDir}/docs/docset.yml"), fileSystem);
var productBNav = new DocumentationSetNavigation<IDocumentationFile>(productBDocsetFile, productBContext, GenericDocumentationFileFactory.Instance);

// Get the "getting-started" folders from each docset
var productAGettingStarted = productANav.NavigationItems.First()
.Should().BeOfType<FolderNavigation<IDocumentationFile>>().Subject;
var productBGettingStarted = productBNav.NavigationItems.First()
.Should().BeOfType<FolderNavigation<IDocumentationFile>>().Subject;

// Both folders have the same relative path within their docsets
productAGettingStarted.FolderPath.Should().Be("getting-started");
productBGettingStarted.FolderPath.Should().Be("getting-started");

// But they MUST have different IDs because they resolve to different URLs
// Product A: /product-a/getting-started
// Product B: /product-b/getting-started
productAGettingStarted.Id.Should().NotBe(productBGettingStarted.Id,
"folders with the same relative path but in different docsets should have different IDs " +
"because they have different URLs");

// Also verify in assembled navigation context
// language=yaml
var siteNavYaml = """
toc:
- toc: product-a://
path_prefix: /product-a
- toc: product-b://
path_prefix: /product-b
""";
var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml);
var documentationSets = new List<IDocumentationSetNavigation> { productANav, productBNav };
var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, productADir, output);
var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null);

// Use production YieldAll() to collect all navigation items
var allNodeItems = ((INavigationTraversable)siteNavigation).YieldAll()
.OfType<INodeNavigationItem<INavigationModel, INavigationItem>>()
.ToList();

// Verify all IDs are unique
var allIds = allNodeItems.Select(n => n.Id).ToList();
var uniqueIds = allIds.Distinct().ToList();
uniqueIds.Should().HaveCount(allIds.Count,
$"all navigation node IDs should be unique in assembled navigation. " +
$"Found duplicates: {string.Join(", ", allIds.GroupBy(id => id).Where(g => g.Count() > 1).Select(g => $"ID '{g.Key}' appears {g.Count()} times"))}");
}

[Theory]
[InlineData(null, "/observability", "/search")]
[InlineData("docs", "/docs/observability", "/docs/search")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,4 +362,5 @@ void VisitNavigationItems(INavigationItem item)

context.Diagnostics.Should().BeEmpty();
}

}
Loading