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.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 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 @@ - + 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.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 + } } 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]