From 3eac8b0e85437e24ff79e94216f002a0d674de98 Mon Sep 17 00:00:00 2001 From: Robert McLaws <1657085+robertmclaws@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:41:36 -0500 Subject: [PATCH 1/4] Add full navigation section type support to ParseNavigationConfig ParseNavigationConfig previously only handled Pages-based navigation, always initializing an empty Pages list even when unused. This adds support for all four remaining Mintlify navigation section types: Tabs, Anchors, Dropdowns, and Products. Changes: - Rework ParseNavigationConfig to detect and populate Tabs, Anchors, Dropdowns, and Products from their XML wrapper elements; Pages initialization is now deferred and only set when explicitly present - Add ParseTabConfig, ParseAnchorConfig, ParseDropdownConfig, and ParseProductConfig internal methods with full XML attribute and nested-element parsing - Add ParseNavigationSectionPages private helper to share Groups/Page parsing logic across all section types - Add 20 new tests covering all parse methods, HTML entity decoding, nested structures, multi-type coexistence, and backward compatibility Co-Authored-By: Claude Sonnet 4.6 --- .../GenerateDocumentationTask.cs | 326 +++++++++- .../GenerateDocumentationTaskTests.cs | 560 ++++++++++++++++++ 2 files changed, 880 insertions(+), 6 deletions(-) diff --git a/src/CloudNimble.DotNetDocs.Sdk.Tasks/GenerateDocumentationTask.cs b/src/CloudNimble.DotNetDocs.Sdk.Tasks/GenerateDocumentationTask.cs index ca2e9c0..daeb9ba 100644 --- a/src/CloudNimble.DotNetDocs.Sdk.Tasks/GenerateDocumentationTask.cs +++ b/src/CloudNimble.DotNetDocs.Sdk.Tasks/GenerateDocumentationTask.cs @@ -639,14 +639,73 @@ internal DocsNavigationConfig ParseDocsNavigationConfig(XElement root) /// A NavigationConfig instance. internal NavigationConfig ParseNavigationConfig(XElement navigationElement) { - var navConfig = new NavigationConfig + var navConfig = new NavigationConfig(); + + // Parse Tabs + var tabsElement = navigationElement.Element("Tabs"); + if (tabsElement is not null) { - Pages = [] - }; + navConfig.Tabs = []; + foreach (var tabElement in tabsElement.Elements("Tab")) + { + var tab = ParseTabConfig(tabElement); + if (tab is not null) + { + navConfig.Tabs.Add(tab); + } + } + } + + // Parse Anchors + var anchorsElement = navigationElement.Element("Anchors"); + if (anchorsElement is not null) + { + navConfig.Anchors = []; + foreach (var anchorElement in anchorsElement.Elements("Anchor")) + { + var anchor = ParseAnchorConfig(anchorElement); + if (anchor is not null) + { + navConfig.Anchors.Add(anchor); + } + } + } + + // Parse Dropdowns + var dropdownsElement = navigationElement.Element("Dropdowns"); + if (dropdownsElement is not null) + { + navConfig.Dropdowns = []; + foreach (var dropdownElement in dropdownsElement.Elements("Dropdown")) + { + var dropdown = ParseDropdownConfig(dropdownElement); + if (dropdown is not null) + { + navConfig.Dropdowns.Add(dropdown); + } + } + } + + // Parse Products + var productsElement = navigationElement.Element("Products"); + if (productsElement is not null) + { + navConfig.Products = []; + foreach (var productElement in productsElement.Elements("Product")) + { + var product = ParseProductConfig(productElement); + if (product is not null) + { + navConfig.Products.Add(product); + } + } + } + // Parse Pages (only if explicitly provided alongside or instead of structured section types) var pagesElement = navigationElement.Element(nameof(NavigationConfig.Pages)); if (pagesElement is not null) { + navConfig.Pages = []; var groupsElement = pagesElement.Element("Groups"); if (groupsElement is not null) { @@ -660,9 +719,7 @@ internal NavigationConfig ParseNavigationConfig(XElement navigationElement) } } - // Also parse any direct page references - var directPages = pagesElement.Elements("Page"); - foreach (var pageElement in directPages) + foreach (var pageElement in pagesElement.Elements("Page")) { var pageName = pageElement.Value?.Trim(); if (!string.IsNullOrWhiteSpace(pageName)) @@ -741,6 +798,263 @@ internal NavigationConfig ParseNavigationConfig(XElement navigationElement) return group; } + /// + /// Parses an Anchor element from the navigation XML. + /// + /// The anchor XML element. + /// An instance, or if parsing fails. + /// + /// Expects a Name attribute on the element. Optional attributes include Href and Icon. + /// Child <Pages> and <Tabs> elements are also parsed if present. + /// + /// + /// + /// <Anchor Name="API Reference" Href="/api" Icon="code"> + /// <Pages> + /// <Groups> + /// <Group Name="Endpoints" Icon="bolt"> + /// <Pages>api/index;api/auth</Pages> + /// </Group> + /// </Groups> + /// </Pages> + /// </Anchor> + /// + /// + internal AnchorConfig? ParseAnchorConfig(XElement anchorElement) + { + var anchorName = anchorElement.Attribute("Name")?.Value; + if (string.IsNullOrWhiteSpace(anchorName)) + { + Log.LogWarning("Anchor element missing Name attribute, skipping"); + return null; + } + + var anchor = new AnchorConfig + { + Anchor = anchorName, + Href = anchorElement.Attribute("Href")?.Value, + Icon = anchorElement.Attribute("Icon")?.Value, + Pages = [] + }; + + ParseNavigationSectionPages(anchorElement, anchor.Pages); + + // Parse nested Tabs within Anchor + var tabsElement = anchorElement.Element("Tabs"); + if (tabsElement is not null) + { + anchor.Tabs = []; + foreach (var tabElement in tabsElement.Elements("Tab")) + { + var tab = ParseTabConfig(tabElement); + if (tab is not null) + { + anchor.Tabs.Add(tab); + } + } + } + + return anchor; + } + + /// + /// Parses a Dropdown element from the navigation XML. + /// + /// The dropdown XML element. + /// A instance, or if parsing fails. + /// + /// Expects a Name attribute on the element. Optional attributes include Href and Icon. + /// Child <Pages>, <Tabs>, and <Anchors> elements are also parsed if present. + /// + /// + /// + /// <Dropdown Name="Products" Icon="grid"> + /// <Anchors> + /// <Anchor Name="Core" Icon="star"> + /// <Pages><Groups><Group Name="Basics"><Pages>core/index</Pages></Group></Groups></Pages> + /// </Anchor> + /// </Anchors> + /// </Dropdown> + /// + /// + internal DropdownConfig? ParseDropdownConfig(XElement dropdownElement) + { + var dropdownName = dropdownElement.Attribute("Name")?.Value; + if (string.IsNullOrWhiteSpace(dropdownName)) + { + Log.LogWarning("Dropdown element missing Name attribute, skipping"); + return null; + } + + var dropdown = new DropdownConfig + { + Dropdown = dropdownName, + Href = dropdownElement.Attribute("Href")?.Value, + Icon = dropdownElement.Attribute("Icon")?.Value, + Pages = [] + }; + + ParseNavigationSectionPages(dropdownElement, dropdown.Pages); + + // Parse nested Tabs within Dropdown + var tabsElement = dropdownElement.Element("Tabs"); + if (tabsElement is not null) + { + dropdown.Tabs = []; + foreach (var tabElement in tabsElement.Elements("Tab")) + { + var tab = ParseTabConfig(tabElement); + if (tab is not null) + { + dropdown.Tabs.Add(tab); + } + } + } + + // Parse nested Anchors within Dropdown + var anchorsElement = dropdownElement.Element("Anchors"); + if (anchorsElement is not null) + { + dropdown.Anchors = []; + foreach (var anchorElement in anchorsElement.Elements("Anchor")) + { + var anchor = ParseAnchorConfig(anchorElement); + if (anchor is not null) + { + dropdown.Anchors.Add(anchor); + } + } + } + + return dropdown; + } + + /// + /// Parses a Product element from the navigation XML. + /// + /// The product XML element. + /// A instance, or if parsing fails. + /// + /// Expects a Name attribute on the element. Optional attributes include Href, Icon, and Description. + /// Child <Pages> elements are also parsed if present. + /// + /// + /// + /// <Product Name="CloudNimble Core" Href="/core" Icon="box" Description="Core platform features"> + /// <Pages> + /// <Groups> + /// <Group Name="Getting Started" Icon="stars"> + /// <Pages>core/index;core/quickstart</Pages> + /// </Group> + /// </Groups> + /// </Pages> + /// </Product> + /// + /// + internal ProductConfig? ParseProductConfig(XElement productElement) + { + var productName = productElement.Attribute("Name")?.Value; + if (string.IsNullOrWhiteSpace(productName)) + { + Log.LogWarning("Product element missing Name attribute, skipping"); + return null; + } + + var product = new ProductConfig + { + Product = productName, + Href = productElement.Attribute("Href")?.Value, + Icon = productElement.Attribute("Icon")?.Value, + Description = productElement.Attribute("Description")?.Value, + Pages = [] + }; + + ParseNavigationSectionPages(productElement, product.Pages); + + return product; + } + + /// + /// Parses a Tab element from the navigation XML. + /// + /// The tab XML element. + /// A instance, or if parsing fails. + /// + /// Expects a Name attribute on the element. Optional attributes include Href and Icon. + /// Child <Pages> elements are also parsed if present. + /// + /// + /// + /// <Tab Name="Guides" Href="/guides" Icon="book"> + /// <Pages> + /// <Groups> + /// <Group Name="Getting Started" Icon="stars"> + /// <Pages>guides/index;guides/quickstart</Pages> + /// </Group> + /// </Groups> + /// </Pages> + /// </Tab> + /// + /// + internal TabConfig? ParseTabConfig(XElement tabElement) + { + var tabName = tabElement.Attribute("Name")?.Value; + if (string.IsNullOrWhiteSpace(tabName)) + { + Log.LogWarning("Tab element missing Name attribute, skipping"); + return null; + } + + var tab = new TabConfig + { + Tab = tabName, + Href = tabElement.Attribute("Href")?.Value, + Icon = tabElement.Attribute("Icon")?.Value, + Pages = [] + }; + + ParseNavigationSectionPages(tabElement, tab.Pages); + + return tab; + } + + /// + /// Parses the <Pages> child element of a navigation section element and populates the provided + /// list with page strings and nested objects. + /// + /// The parent XML element that may contain a <Pages> child. + /// The list to populate with parsed page entries. + private void ParseNavigationSectionPages(XElement parentElement, List pages) + { + var pagesElement = parentElement.Element("Pages"); + if (pagesElement is null) + { + return; + } + + var groupsElement = pagesElement.Element("Groups"); + if (groupsElement is not null) + { + foreach (var groupElement in groupsElement.Elements("Group")) + { + var group = ParseGroupConfig(groupElement); + if (group is not null) + { + pages.Add(group); + } + } + } + + foreach (var pageElement in pagesElement.Elements("Page")) + { + var pageName = pageElement.Value?.Trim(); + if (!string.IsNullOrWhiteSpace(pageName)) + { + pages.Add(pageName); + } + } + } + /// /// Parses the Integrations element from the MintlifyTemplate XML using attributes. /// diff --git a/src/CloudNimble.DotNetDocs.Tests.Sdk.Tasks/GenerateDocumentationTaskTests.cs b/src/CloudNimble.DotNetDocs.Tests.Sdk.Tasks/GenerateDocumentationTaskTests.cs index 4e1f712..667b988 100644 --- a/src/CloudNimble.DotNetDocs.Tests.Sdk.Tasks/GenerateDocumentationTaskTests.cs +++ b/src/CloudNimble.DotNetDocs.Tests.Sdk.Tasks/GenerateDocumentationTaskTests.cs @@ -835,6 +835,566 @@ public void ParseInteractionConfig_WithCaseInsensitiveBooleans_ParsesCorrectly() #endregion + #region Tab, Anchor, Dropdown, and Product Parsing Tests + + /// + /// Tests that ParseTabConfig correctly extracts tab name, href, and pages from XML. + /// + [TestMethod] + public void ParseTabConfig_WithNameAndHref_ParsesCorrectly() + { + // Arrange + var xml = """ + + + guides/index + guides/quickstart + + + """; + var tabElement = XElement.Parse(xml); + + // Act + var result = _task.ParseTabConfig(tabElement); + + // Assert + result.Should().NotBeNull(); + result!.Tab.Should().Be("Guides"); + result.Href.Should().Be("/guides"); + result.Pages.Should().HaveCount(2); + result.Pages![0].Should().Be("guides/index"); + result.Pages![1].Should().Be("guides/quickstart"); + } + + /// + /// Tests that ParseTabConfig correctly decodes HTML-encoded characters in the Name attribute. + /// + [TestMethod] + public void ParseTabConfig_WithHtmlEncodedName_DecodesCorrectly() + { + // Arrange + var xml = ""; + var tabElement = XElement.Parse(xml); + + // Act + var result = _task.ParseTabConfig(tabElement); + + // Assert + result.Should().NotBeNull(); + result!.Tab.Should().Be("S&S Landscape Design"); + } + + /// + /// Tests that ParseTabConfig returns null when the Name attribute is missing. + /// + [TestMethod] + public void ParseTabConfig_WithoutName_ReturnsNull() + { + // Arrange + var xml = ""; + var tabElement = XElement.Parse(xml); + + // Act + var result = _task.ParseTabConfig(tabElement); + + // Assert + result.Should().BeNull(); + } + + /// + /// Tests that ParseTabConfig correctly parses nested groups within a tab. + /// + [TestMethod] + public void ParseTabConfig_WithNestedGroups_ParsesHierarchy() + { + // Arrange + var xml = """ + + + + + api/index;api/auth + + + + + """; + var tabElement = XElement.Parse(xml); + + // Act + var result = _task.ParseTabConfig(tabElement); + + // Assert + result.Should().NotBeNull(); + result!.Tab.Should().Be("API Reference"); + result.Pages.Should().HaveCount(1); + result.Pages![0].Should().BeOfType(); + var group = result.Pages![0] as GroupConfig; + group!.Group.Should().Be("Endpoints"); + group.Icon!.Name.Should().Be("bolt"); + group.Pages.Should().HaveCount(2); + } + + /// + /// Tests that ParseTabConfig sets Href to null when the attribute is absent. + /// + [TestMethod] + public void ParseTabConfig_WithoutHref_HasNullHref() + { + // Arrange + var xml = ""; + var tabElement = XElement.Parse(xml); + + // Act + var result = _task.ParseTabConfig(tabElement); + + // Assert + result.Should().NotBeNull(); + result!.Href.Should().BeNull(); + } + + /// + /// Tests that ParseTabConfig correctly sets the Icon property from the attribute. + /// + [TestMethod] + public void ParseTabConfig_WithIcon_SetsIcon() + { + // Arrange + var xml = ""; + var tabElement = XElement.Parse(xml); + + // Act + var result = _task.ParseTabConfig(tabElement); + + // Assert + result.Should().NotBeNull(); + result!.Icon.Should().NotBeNull(); + result.Icon!.Name.Should().Be("book"); + } + + /// + /// Tests that ParseAnchorConfig correctly extracts anchor name, href, and pages from XML. + /// + [TestMethod] + public void ParseAnchorConfig_WithNameAndHref_ParsesCorrectly() + { + // Arrange + var xml = """ + + + api/index + + + """; + var anchorElement = XElement.Parse(xml); + + // Act + var result = _task.ParseAnchorConfig(anchorElement); + + // Assert + result.Should().NotBeNull(); + result!.Anchor.Should().Be("API Reference"); + result.Href.Should().Be("/api"); + result.Icon!.Name.Should().Be("code"); + result.Pages.Should().HaveCount(1); + result.Pages![0].Should().Be("api/index"); + } + + /// + /// Tests that ParseAnchorConfig returns null when the Name attribute is missing. + /// + [TestMethod] + public void ParseAnchorConfig_WithoutName_ReturnsNull() + { + // Arrange + var xml = ""; + var anchorElement = XElement.Parse(xml); + + // Act + var result = _task.ParseAnchorConfig(anchorElement); + + // Assert + result.Should().BeNull(); + } + + /// + /// Tests that ParseAnchorConfig correctly parses nested tabs within an anchor. + /// + [TestMethod] + public void ParseAnchorConfig_WithNestedTabs_ParsesTabs() + { + // Arrange + var xml = """ + + + + + core/index + + + + + + """; + var anchorElement = XElement.Parse(xml); + + // Act + var result = _task.ParseAnchorConfig(anchorElement); + + // Assert + result.Should().NotBeNull(); + result!.Anchor.Should().Be("Platform"); + result.Tabs.Should().HaveCount(2); + result.Tabs![0].Tab.Should().Be("Core"); + result.Tabs![0].Href.Should().Be("/core"); + result.Tabs![1].Tab.Should().Be("Extensions"); + } + + /// + /// Tests that ParseDropdownConfig correctly extracts dropdown name, href, and pages from XML. + /// + [TestMethod] + public void ParseDropdownConfig_WithNameAndHref_ParsesCorrectly() + { + // Arrange + var xml = """ + + + products/index + + + """; + var dropdownElement = XElement.Parse(xml); + + // Act + var result = _task.ParseDropdownConfig(dropdownElement); + + // Assert + result.Should().NotBeNull(); + result!.Dropdown.Should().Be("Products"); + result.Href.Should().Be("/products"); + result.Icon!.Name.Should().Be("grid"); + result.Pages.Should().HaveCount(1); + result.Pages![0].Should().Be("products/index"); + } + + /// + /// Tests that ParseDropdownConfig returns null when the Name attribute is missing. + /// + [TestMethod] + public void ParseDropdownConfig_WithoutName_ReturnsNull() + { + // Arrange + var xml = ""; + var dropdownElement = XElement.Parse(xml); + + // Act + var result = _task.ParseDropdownConfig(dropdownElement); + + // Assert + result.Should().BeNull(); + } + + /// + /// Tests that ParseDropdownConfig correctly parses nested tabs and anchors within a dropdown. + /// + [TestMethod] + public void ParseDropdownConfig_WithNestedTabsAndAnchors_ParsesBoth() + { + // Arrange + var xml = """ + + + + + + + + + """; + var dropdownElement = XElement.Parse(xml); + + // Act + var result = _task.ParseDropdownConfig(dropdownElement); + + // Assert + result.Should().NotBeNull(); + result!.Dropdown.Should().Be("Platform"); + result.Tabs.Should().HaveCount(1); + result.Tabs![0].Tab.Should().Be("Core"); + result.Anchors.Should().HaveCount(1); + result.Anchors![0].Anchor.Should().Be("API Reference"); + } + + /// + /// Tests that ParseProductConfig correctly extracts product name, href, and pages from XML. + /// + [TestMethod] + public void ParseProductConfig_WithNameAndHref_ParsesCorrectly() + { + // Arrange + var xml = """ + + + core/index + core/quickstart + + + """; + var productElement = XElement.Parse(xml); + + // Act + var result = _task.ParseProductConfig(productElement); + + // Assert + result.Should().NotBeNull(); + result!.Product.Should().Be("CloudNimble Core"); + result.Href.Should().Be("/core"); + result.Icon!.Name.Should().Be("box"); + result.Pages.Should().HaveCount(2); + result.Pages![0].Should().Be("core/index"); + result.Pages![1].Should().Be("core/quickstart"); + } + + /// + /// Tests that ParseProductConfig correctly parses the Description attribute. + /// + [TestMethod] + public void ParseProductConfig_WithDescription_ParsesDescription() + { + // Arrange + var xml = ""; + var productElement = XElement.Parse(xml); + + // Act + var result = _task.ParseProductConfig(productElement); + + // Assert + result.Should().NotBeNull(); + result!.Description.Should().Be("Core platform features"); + } + + /// + /// Tests that ParseProductConfig returns null when the Name attribute is missing. + /// + [TestMethod] + public void ParseProductConfig_WithoutName_ReturnsNull() + { + // Arrange + var xml = ""; + var productElement = XElement.Parse(xml); + + // Act + var result = _task.ParseProductConfig(productElement); + + // Assert + result.Should().BeNull(); + } + + /// + /// Tests that ParseNavigationConfig populates Tabs and leaves Pages null when only Tabs are defined. + /// + [TestMethod] + public void ParseNavigationConfig_WithTabsElement_PopulatesTabsNotPages() + { + // Arrange + var xml = """ + + + + + + + """; + var navigationElement = XElement.Parse(xml); + + // Act + var result = _task.ParseNavigationConfig(navigationElement); + + // Assert + result.Should().NotBeNull(); + result.Tabs.Should().HaveCount(2); + result.Pages.Should().BeNull(); + } + + /// + /// Tests that ParseNavigationConfig preserves the order of tabs as defined in the template. + /// + [TestMethod] + public void ParseNavigationConfig_WithTabsElement_PreservesTabOrder() + { + // Arrange + var xml = """ + + + + + + + + + + """; + var navigationElement = XElement.Parse(xml); + + // Act + var result = _task.ParseNavigationConfig(navigationElement); + + // Assert + result.Tabs.Should().HaveCount(5); + result.Tabs![0].Tab.Should().Be("S&S Landscape Design"); + result.Tabs![1].Tab.Should().Be("Scott Leese Consulting"); + result.Tabs![2].Tab.Should().Be("Surf & Sales"); + result.Tabs![3].Tab.Should().Be("Surf & Sales Podcast"); + result.Tabs![4].Tab.Should().Be("What's Your Story"); + } + + /// + /// Tests that ParseNavigationConfig populates Anchors when an Anchors element is present. + /// + [TestMethod] + public void ParseNavigationConfig_WithAnchorsElement_PopulatesAnchors() + { + // Arrange + var xml = """ + + + + + + + """; + var navigationElement = XElement.Parse(xml); + + // Act + var result = _task.ParseNavigationConfig(navigationElement); + + // Assert + result.Anchors.Should().HaveCount(2); + result.Anchors![0].Anchor.Should().Be("Docs"); + result.Anchors![1].Anchor.Should().Be("API"); + result.Pages.Should().BeNull(); + } + + /// + /// Tests that ParseNavigationConfig populates Dropdowns when a Dropdowns element is present. + /// + [TestMethod] + public void ParseNavigationConfig_WithDropdownsElement_PopulatesDropdowns() + { + // Arrange + var xml = """ + + + + + + + """; + var navigationElement = XElement.Parse(xml); + + // Act + var result = _task.ParseNavigationConfig(navigationElement); + + // Assert + result.Dropdowns.Should().HaveCount(2); + result.Dropdowns![0].Dropdown.Should().Be("Platform"); + result.Dropdowns![1].Dropdown.Should().Be("Tools"); + result.Pages.Should().BeNull(); + } + + /// + /// Tests that ParseNavigationConfig populates Products when a Products element is present. + /// + [TestMethod] + public void ParseNavigationConfig_WithProductsElement_PopulatesProducts() + { + // Arrange + var xml = """ + + + + + + + """; + var navigationElement = XElement.Parse(xml); + + // Act + var result = _task.ParseNavigationConfig(navigationElement); + + // Assert + result.Products.Should().HaveCount(2); + result.Products![0].Product.Should().Be("Core SDK"); + result.Products![0].Description.Should().Be("The core library"); + result.Products![1].Product.Should().Be("Extensions"); + result.Pages.Should().BeNull(); + } + + /// + /// Tests that ParseNavigationConfig can populate both Tabs and Anchors simultaneously. + /// + [TestMethod] + public void ParseNavigationConfig_WithTabsAndAnchors_PopulatesBoth() + { + // Arrange + var xml = """ + + + + + + + + + """; + var navigationElement = XElement.Parse(xml); + + // Act + var result = _task.ParseNavigationConfig(navigationElement); + + // Assert + result.Tabs.Should().HaveCount(1); + result.Tabs![0].Tab.Should().Be("Guides"); + result.Anchors.Should().HaveCount(1); + result.Anchors![0].Anchor.Should().Be("API"); + result.Pages.Should().BeNull(); + } + + /// + /// Tests that ParseNavigationConfig still correctly processes Pages-based navigation for backward compatibility. + /// + [TestMethod] + public void ParseNavigationConfig_WithPagesElement_StillWorks() + { + // Arrange + var xml = """ + + + + + index;quickstart + + + + + """; + var navigationElement = XElement.Parse(xml); + + // Act + var result = _task.ParseNavigationConfig(navigationElement); + + // Assert + result.Pages.Should().HaveCount(1); + result.Pages![0].Should().BeOfType(); + var group = result.Pages![0] as GroupConfig; + group!.Group.Should().Be("Getting Started"); + result.Tabs.Should().BeNull(); + result.Anchors.Should().BeNull(); + } + + #endregion + #region DocumentationReference Integration Tests [TestMethod] From 3350374a1f35b28a6c831044654bbc2843d8829f Mon Sep 17 00:00:00 2001 From: Robert McLaws <1657085+robertmclaws@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:43:39 -0500 Subject: [PATCH 2/4] Bump SDK version to 1.3.0, fix EasyAF.MSBuild version constraint, move specs to future - Bump DotNetDocs.Sdk reference from 1.2.0 to 1.3.0 in both .docsproj files - Change EasyAF.MSBuild version constraint from 4.*-* to 4.* to resolve NU1107 conflict - Delete specs/semantic-kernel-integration.md and specs/try-dotnet.md (moved to specs/future/) - Add specs/future/ with moved specs and contributors.md Co-Authored-By: Claude Sonnet 4.6 --- specs/future/contributors.md | 142 ++++++++++++++++++ .../semantic-kernel-integration.md | 0 specs/{ => future}/try-dotnet.md | 0 .../CloudNimble.DotNetDocs.Docs.docsproj | 2 +- ...ble.DotNetDocs.Reference.Mintlify.docsproj | 2 +- .../CloudNimble.DotNetDocs.Sdk.Tasks.csproj | 2 +- 6 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 specs/future/contributors.md rename specs/{ => future}/semantic-kernel-integration.md (100%) rename specs/{ => future}/try-dotnet.md (100%) diff --git a/specs/future/contributors.md b/specs/future/contributors.md new file mode 100644 index 0000000..84a36d7 --- /dev/null +++ b/specs/future/contributors.md @@ -0,0 +1,142 @@ +# GitHub Contributors Feature + +## Overview + +Add contributor avatars with profile links to documentation pages using git + one GitHub API call. + +## Approach (Hybrid) + +1. One-time call to GitHub's `/repos/{owner}/{repo}/contributors` endpoint → cache email→user mapping +2. Use `git log` locally to get contributor emails per file +3. Match emails to cached GitHub usernames +4. Construct URLs: `https://github.com/{username}.png` (avatar), `https://github.com/{username}` (profile) + +## Core Model + +**New file: `src/CloudNimble.DotNetDocs.Core/Contributor.cs`** + +```csharp +public class Contributor +{ + public string Name { get; set; } + public string Email { get; set; } + public string? Username { get; set; } + public string? AvatarUrl { get; set; } + public string? ProfileUrl { get; set; } +} +``` + +**Modify: `src/CloudNimble.DotNetDocs.Core/DocEntity.cs`** + +```csharp +public List? Contributors { get; set; } +``` + +**Modify: `src/CloudNimble.DotNetDocs.Core/ProjectContext.cs`** + +```csharp +public bool ContributorsEnabled { get; set; } = false; +``` + +## Implementation + +### 1. GitHelper (in Core for reuse by future GitLab/AzureDevOps plugins) + +**New file: `src/CloudNimble.DotNetDocs.Core/GitHelper.cs`** + +```csharp +public static class GitHelper +{ + // git log --format="%an|%ae" -- {filePath} | sort -u + public static List<(string Name, string Email)> GetFileContributors(string filePath); + + // git remote get-url origin + public static string? GetRemoteUrl(); + + // Parse "https://github.com/Owner/Repo.git" → (Provider, Owner, Repo) + public static (string? Provider, string? Owner, string? Repo) ParseRemoteUrl(string url); +} +``` + +### 2. GitHubContributorEnricher (in GitHub plugin) + +**New file: `src/CloudNimble.DotNetDocs.Plugins.GitHub/GitHubContributorEnricher.cs`** + +Implements `IDocEnricher`: + +```csharp +public class GitHubContributorEnricher : IDocEnricher +{ + private Dictionary? _contributorCache; // email → Contributor + + public async Task EnrichAsync(DocEntity entity) + { + if (entity is not DocAssembly assembly) return; + + // 1. Build cache once: GET /repos/{owner}/{repo}/contributors + await BuildContributorCacheAsync(); + + // 2. Walk entity graph recursively + foreach (var ns in assembly.Namespaces) + foreach (var type in ns.Types) + EnrichType(type); + } + + private void EnrichType(DocType type) + { + var contributors = new HashSet(); + + // Source code file + var sourcePath = type.Symbol?.Locations.FirstOrDefault()?.SourceTree?.FilePath; + if (sourcePath is not null) + AddContributorsFromFile(sourcePath, contributors); + + // Conceptual files (Usage.md, Examples.md, etc.) + // ... get paths from ProjectContext.GetFullConceptualPath() + type path + + type.Contributors = contributors.ToList(); + } +} +``` + +### 3. Non-API Docs (Guides, etc.) + +**TODO: Define rendering approach** + +For non-generated pages (guides, tutorials, etc.), contributors need to be collected on-the-fly and rendered. Options: + +- **Mintlify Component**: Create a `` React component +- **Markdown Injection**: Renderer injects contributor HTML directly into .mdx files + +Example output: + +```html +
+ username + ... +
+``` + +## Files to Create/Modify + +| File | Action | +|------|--------| +| `src/CloudNimble.DotNetDocs.Core/Contributor.cs` | Create | +| `src/CloudNimble.DotNetDocs.Core/GitHelper.cs` | Create | +| `src/CloudNimble.DotNetDocs.Core/DocEntity.cs` | Add `Contributors` property | +| `src/CloudNimble.DotNetDocs.Core/ProjectContext.cs` | Add `ContributorsEnabled` flag | +| `src/CloudNimble.DotNetDocs.Plugins.GitHub/GitHubContributorEnricher.cs` | Create | + +## Open Items + +- [ ] Define Mintlify vs Markdown rendering approach +- [ ] Determine where contributor section appears in rendered output (top, bottom, sidebar?) +- [ ] Handle case where GitHub API is unavailable (graceful degradation to name/email only?) +- [ ] Consider caching the contributor cache to disk to avoid API calls on every build + +## Verification + +1. `dotnet build src -c Debug` - ensure it compiles +2. Run against a project with git history +3. Debug/inspect that `DocType.Contributors` is populated +4. Check rendered .mdx output includes contributor avatars diff --git a/specs/semantic-kernel-integration.md b/specs/future/semantic-kernel-integration.md similarity index 100% rename from specs/semantic-kernel-integration.md rename to specs/future/semantic-kernel-integration.md diff --git a/specs/try-dotnet.md b/specs/future/try-dotnet.md similarity index 100% rename from specs/try-dotnet.md rename to specs/future/try-dotnet.md diff --git a/src/CloudNimble.DotNetDocs.Docs/CloudNimble.DotNetDocs.Docs.docsproj b/src/CloudNimble.DotNetDocs.Docs/CloudNimble.DotNetDocs.Docs.docsproj index 00912f0..293415a 100644 --- a/src/CloudNimble.DotNetDocs.Docs/CloudNimble.DotNetDocs.Docs.docsproj +++ b/src/CloudNimble.DotNetDocs.Docs/CloudNimble.DotNetDocs.Docs.docsproj @@ -1,4 +1,4 @@ - + Mintlify diff --git a/src/CloudNimble.DotNetDocs.Reference.Mintlify/CloudNimble.DotNetDocs.Reference.Mintlify.docsproj b/src/CloudNimble.DotNetDocs.Reference.Mintlify/CloudNimble.DotNetDocs.Reference.Mintlify.docsproj index 37049ac..d837e80 100644 --- a/src/CloudNimble.DotNetDocs.Reference.Mintlify/CloudNimble.DotNetDocs.Reference.Mintlify.docsproj +++ b/src/CloudNimble.DotNetDocs.Reference.Mintlify/CloudNimble.DotNetDocs.Reference.Mintlify.docsproj @@ -1,4 +1,4 @@ - + Mintlify true diff --git a/src/CloudNimble.DotNetDocs.Sdk.Tasks/CloudNimble.DotNetDocs.Sdk.Tasks.csproj b/src/CloudNimble.DotNetDocs.Sdk.Tasks/CloudNimble.DotNetDocs.Sdk.Tasks.csproj index 85a1283..4cbc1d7 100644 --- a/src/CloudNimble.DotNetDocs.Sdk.Tasks/CloudNimble.DotNetDocs.Sdk.Tasks.csproj +++ b/src/CloudNimble.DotNetDocs.Sdk.Tasks/CloudNimble.DotNetDocs.Sdk.Tasks.csproj @@ -20,7 +20,7 @@ - + From 89c4049c5d34baac9e3598be136da8aa20fc241a Mon Sep 17 00:00:00 2001 From: Robert McLaws <1657085+robertmclaws@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:32:16 -0500 Subject: [PATCH 3/4] Skip auto-navigation population when template defines explicit sections When a MintlifyTemplate specifies Tabs, Anchors, Dropdowns, or Products directly in the XML, the renderer was still calling PopulateNavigationFromPath which unconditionally initialized Pages and auto-discovered MDX files from disk. This produced a spurious "navigation.pages" block alongside the explicit "navigation.tabs" in the output docs.json. Fix by checking for explicit navigation sections after loading the template config and skipping both PopulateNavigationFromPath and BuildNavigationStructure when they are present. The NavigationType-based auto-generation path (where Pages are discovered then moved to a Tab by ApplyNavigationType) is unaffected. Co-Authored-By: Claude Sonnet 4.6 --- .../MintlifyRenderer.cs | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/CloudNimble.DotNetDocs.Mintlify/MintlifyRenderer.cs b/src/CloudNimble.DotNetDocs.Mintlify/MintlifyRenderer.cs index 5dabfc5..d9c4e9f 100644 --- a/src/CloudNimble.DotNetDocs.Mintlify/MintlifyRenderer.cs +++ b/src/CloudNimble.DotNetDocs.Mintlify/MintlifyRenderer.cs @@ -136,14 +136,27 @@ public async Task RenderAsync(DocAssembly? model) .Where(d => !string.IsNullOrWhiteSpace(d)) .ToArray(); - // First: Discover existing MDX files in documentation root, preserving template navigation - // Exclude DocumentationReference output directories to prevent them from being treated as conceptual docs - _docsJsonManager.PopulateNavigationFromPath(Context.DocumentationRootPath, new[] { ".mdx" }, includeApiReference: false, preserveExisting: true, excludeDirectories: excludeDirectories); - - // Second: Add API reference content to existing navigation ONLY if model exists - if (model is not null) - { - BuildNavigationStructure(_docsJsonManager.Configuration!, model); + // Determine whether the template already defines explicit navigation sections. + // When Tabs, Anchors, Dropdowns, or Products are present, all navigation is + // managed by the template — auto-discovery into Pages would produce a spurious + // parallel "pages" block alongside the explicit sections. + var templateNav = _docsJsonManager.Configuration?.Navigation; + var hasExplicitSections = templateNav?.Tabs is not null + || templateNav?.Anchors is not null + || templateNav?.Dropdowns is not null + || templateNav?.Products is not null; + + if (!hasExplicitSections) + { + // First: Discover existing MDX files in documentation root, preserving template navigation + // Exclude DocumentationReference output directories to prevent them from being treated as conceptual docs + _docsJsonManager.PopulateNavigationFromPath(Context.DocumentationRootPath, new[] { ".mdx" }, includeApiReference: false, preserveExisting: true, excludeDirectories: excludeDirectories); + + // Second: Add API reference content to existing navigation ONLY if model exists + if (model is not null) + { + BuildNavigationStructure(_docsJsonManager.Configuration!, model); + } } // Third: Apply NavigationType from template to move root content to Tabs/Products if configured From 648d18bd9aa0d0ee7fe94396bc5632b95f4d5de5 Mon Sep 17 00:00:00 2001 From: Robert McLaws <1657085+robertmclaws@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:16:02 -0500 Subject: [PATCH 4/4] Add renderer-level tests for explicit template navigation sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser-layer tests (ParseNavigationConfig_WithTabsElement_PopulatesTabsNotPages etc.) already verified that Pages comes back null when only Tabs are defined in XML. However, the actual bug was one layer deeper — in MintlifyRenderer.ProcessAsync, which called PopulateNavigationFromPath and injected a spurious pages block regardless. This adds: - contentOnly parameter to ConfigureTestWithTemplate so tests can invoke ProcessAsync([]) (documentation-only mode) with HasMintlifyTemplate set - ExplicitTemplateTabs_ContentOnly_HasTabsAndNoPages - ExplicitTemplateTabs_WithAssembly_HasOnlyTabsAndNoPages (regression) - ExplicitTemplateAnchors_ContentOnly_HasAnchorsAndNoPages - ExplicitTemplateProducts_ContentOnly_HasProductsAndNoPages Each test asserts that Navigation.Pages is null in the deserialized docs.json when the template defines an explicit navigation section type, which is the behavior introduced by the MintlifyRenderer fix. Co-Authored-By: Claude Sonnet 4.6 --- .../MintlifyRendererNavigationTypeTests.cs | 183 +++++++++++++++++- 1 file changed, 182 insertions(+), 1 deletion(-) diff --git a/src/CloudNimble.DotNetDocs.Tests.Mintlify/Renderers/MintlifyRendererNavigationTypeTests.cs b/src/CloudNimble.DotNetDocs.Tests.Mintlify/Renderers/MintlifyRendererNavigationTypeTests.cs index 0970134..304b94b 100644 --- a/src/CloudNimble.DotNetDocs.Tests.Mintlify/Renderers/MintlifyRendererNavigationTypeTests.cs +++ b/src/CloudNimble.DotNetDocs.Tests.Mintlify/Renderers/MintlifyRendererNavigationTypeTests.cs @@ -38,7 +38,14 @@ public class MintlifyRendererNavigationTypeTests : DotNetDocsTestBase /// Configures test host with a specific template and navigation configuration. /// This must be called before TestSetup() to ensure the configuration is set before services are built. /// - private void ConfigureTestWithTemplate(DocsJsonConfig? template, DocsNavigationConfig? navConfig = null) + /// The DocsJsonConfig template to use, or null for no template. + /// Optional navigation configuration to override defaults. + /// + /// When , sets HasMintlifyTemplate = true on the project context so that + /// + /// with an empty assembly list still runs the renderer pipeline (content-only mode). + /// + private void ConfigureTestWithTemplate(DocsJsonConfig? template, DocsNavigationConfig? navConfig = null, bool contentOnly = false) { _testOutputPath = Path.Combine(Path.GetTempPath(), $"MintlifyNavigationTypeTest_{Guid.NewGuid()}"); Directory.CreateDirectory(_testOutputPath); @@ -59,6 +66,10 @@ private void ConfigureTestWithTemplate(DocsJsonConfig? template, DocsNavigationC { ctx.DocumentationRootPath = _testOutputPath; ctx.ApiReferencePath = string.Empty; + if (contentOnly) + { + ctx.HasMintlifyTemplate = true; + } }); }); }); @@ -333,6 +344,176 @@ public async Task ApplyNavigationType_WithInvalidType_UsesDefaultBehavior() #endregion + #region Explicit Template Navigation Section Tests + + /// + /// Regression test: when a template defines explicit Tabs in the Navigation config, the renderer + /// must not auto-generate a parallel pages block from disk discovery (content-only mode). + /// + [TestMethod] + public async Task ExplicitTemplateTabs_ContentOnly_HasTabsAndNoPages() + { + // Arrange + ConfigureTestWithTemplate( + new DocsJsonConfig + { + Name = "Test Docs", + Theme = "mint", + Colors = new ColorsConfig { Primary = "#000000" }, + Navigation = new NavigationConfig + { + Tabs = + [ + new TabConfig { Tab = "Guides", Href = "/guides", Pages = ["guides/index"] }, + new TabConfig { Tab = "API Reference", Href = "/api", Pages = ["api/index"] } + ] + } + }, + contentOnly: true); + + var documentationManager = GetService(); + + // Act + await documentationManager.ProcessAsync([]); + + // Assert + var docsJsonPath = Path.Combine(_testOutputPath, "docs.json"); + File.Exists(docsJsonPath).Should().BeTrue(); + + var json = File.ReadAllText(docsJsonPath); + var config = JsonSerializer.Deserialize(json, MintlifyConstants.JsonSerializerOptions); + + config.Should().NotBeNull(); + config!.Navigation.Tabs.Should().HaveCount(2, "both template-defined tabs should be present"); + config.Navigation.Tabs![0].Tab.Should().Be("Guides"); + config.Navigation.Tabs![1].Tab.Should().Be("API Reference"); + config.Navigation.Pages.Should().BeNull("explicit tabs navigation must not produce a spurious pages block"); + } + + /// + /// Regression test: when a template defines explicit Tabs, auto-discovery must not inject a pages block + /// even when a real assembly is also being processed. + /// + [TestMethod] + public async Task ExplicitTemplateTabs_WithAssembly_HasOnlyTabsAndNoPages() + { + // Arrange + ConfigureTestWithTemplate(new DocsJsonConfig + { + Name = "Test API", + Theme = "mint", + Colors = new ColorsConfig { Primary = "#000000" }, + Navigation = new NavigationConfig + { + Tabs = + [ + new TabConfig { Tab = "Getting Started", Href = "/start", Pages = ["index"] }, + new TabConfig { Tab = "API Reference", Href = "/api", Pages = ["api/index"] } + ] + } + }); + + var assemblyPath = typeof(SimpleClass).Assembly.Location; + var xmlPath = Path.ChangeExtension(assemblyPath, ".xml"); + var documentationManager = GetService(); + + // Act + await documentationManager.ProcessAsync(assemblyPath, xmlPath); + + // Assert + var docsJsonPath = Path.Combine(_testOutputPath, "docs.json"); + var json = File.ReadAllText(docsJsonPath); + var config = JsonSerializer.Deserialize(json, MintlifyConstants.JsonSerializerOptions); + + config.Should().NotBeNull(); + config!.Navigation.Tabs.Should().HaveCount(2, "template-defined tabs should be preserved"); + config.Navigation.Pages.Should().BeNull("auto-discovery must not inject pages alongside explicit tabs even when an assembly is present"); + } + + /// + /// Verifies that a template with explicit Anchors produces anchors and no pages in content-only mode. + /// + [TestMethod] + public async Task ExplicitTemplateAnchors_ContentOnly_HasAnchorsAndNoPages() + { + // Arrange + ConfigureTestWithTemplate( + new DocsJsonConfig + { + Name = "Test Docs", + Theme = "mint", + Colors = new ColorsConfig { Primary = "#000000" }, + Navigation = new NavigationConfig + { + Anchors = + [ + new AnchorConfig { Anchor = "Documentation", Href = "/docs", Icon = "book", Pages = [] }, + new AnchorConfig { Anchor = "API", Href = "/api", Icon = "code", Pages = [] } + ] + } + }, + contentOnly: true); + + var documentationManager = GetService(); + + // Act + await documentationManager.ProcessAsync([]); + + // Assert + var docsJsonPath = Path.Combine(_testOutputPath, "docs.json"); + var json = File.ReadAllText(docsJsonPath); + var config = JsonSerializer.Deserialize(json, MintlifyConstants.JsonSerializerOptions); + + config.Should().NotBeNull(); + config!.Navigation.Anchors.Should().HaveCount(2, "both template-defined anchors should be present"); + config.Navigation.Anchors![0].Anchor.Should().Be("Documentation"); + config.Navigation.Anchors![1].Anchor.Should().Be("API"); + config.Navigation.Pages.Should().BeNull("explicit anchors navigation must not produce a spurious pages block"); + } + + /// + /// Verifies that a template with explicit Products produces products and no pages in content-only mode. + /// + [TestMethod] + public async Task ExplicitTemplateProducts_ContentOnly_HasProductsAndNoPages() + { + // Arrange + ConfigureTestWithTemplate( + new DocsJsonConfig + { + Name = "Test Platform", + Theme = "mint", + Colors = new ColorsConfig { Primary = "#000000" }, + Navigation = new NavigationConfig + { + Products = + [ + new ProductConfig { Product = "Core SDK", Href = "/core", Pages = ["core/index"] }, + new ProductConfig { Product = "Extensions", Href = "/ext", Pages = ["ext/index"] } + ] + } + }, + contentOnly: true); + + var documentationManager = GetService(); + + // Act + await documentationManager.ProcessAsync([]); + + // Assert + var docsJsonPath = Path.Combine(_testOutputPath, "docs.json"); + var json = File.ReadAllText(docsJsonPath); + var config = JsonSerializer.Deserialize(json, MintlifyConstants.JsonSerializerOptions); + + config.Should().NotBeNull(); + config!.Navigation.Products.Should().HaveCount(2, "both template-defined products should be present"); + config.Navigation.Products![0].Product.Should().Be("Core SDK"); + config.Navigation.Products![1].Product.Should().Be("Extensions"); + config.Navigation.Pages.Should().BeNull("explicit products navigation must not produce a spurious pages block"); + } + + #endregion + } }