diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index ac35b31e6..2a21b055e 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -12,11 +12,11 @@ jobs: matrix: include: - repository: elastic/docs-content - landing-page-path-output: /docs/ + landing-page-path-output: /docs - repository: elastic/elasticsearch - landing-page-path-output: /docs/reference/ + landing-page-path-output: /docs/reference - repository: elastic/opentelemetry - landing-page-path-output: /docs/reference/ + landing-page-path-output: /docs/reference # This is a random repository that should not have a docset.yml - repository: elastic/oblt-actions diff --git a/Directory.Packages.props b/Directory.Packages.props index bd121ef7f..b08adf805 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,7 @@ + @@ -26,6 +27,7 @@ + diff --git a/NAVIGATION_REFACTOR_DEEP_DIVE.md b/NAVIGATION_REFACTOR_DEEP_DIVE.md new file mode 100644 index 000000000..37b65cf79 --- /dev/null +++ b/NAVIGATION_REFACTOR_DEEP_DIVE.md @@ -0,0 +1,478 @@ +# Navigation Refactor - Technical Deep Dive + +**Branch:** `refactor/navigation` +**Base:** `main` +**Date:** 2025-11-04 + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Core Architecture Changes](#core-architecture-changes) +3. [Type System Improvements: Covariance](#type-system-improvements-covariance) +4. [The Home Provider Pattern](#the-home-provider-pattern) +5. [Two-Phase Navigation Building](#two-phase-navigation-building) +6. [Detailed Technical Improvements](#detailed-technical-improvements) + +--- + +## Executive Summary + +This refactor represents a **complete rewrite of the navigation system**, moving from a monolithic, tightly-coupled approach to a modern, functional architecture with: + +- **Type-safe covariant interfaces** - Proper use of C# variance for safer, more flexible APIs +- **O(1) URL re-homing** - Change URL prefixes without traversing trees via the Home Provider pattern +- **Two-phase building** - Isolated per-docset navigation + cross-docset assembly +- **Immutable data structures** - Functional programming principles reduce bugs +- **Separation of concerns** - Configuration, navigation, and rendering are independent + +**Impact:** 169 files changed, 13,872 additions, 2,756 deletions + +--- + +## Core Architecture Changes + +### Before: Monolithic Navigation (main branch) + +``` +┌─────────────────────────────────────────┐ +│ GlobalNavigation │ +│ - Builds everything at once │ +│ - Tightly coupled to markdown parsing │ +│ - URLs baked in at construction │ +│ - Mixed configuration + navigation │ +└─────────────────────────────────────────┘ + │ + ┌───────────┴───────────┐ + │ │ + DocumentationGroup TableOfContentsTree + │ │ + FileNavigationItem CrossLinkNavigationItem +``` + +**Problems:** +1. **Tight coupling** - Navigation building happens during markdown parsing +2. **No reusability** - Can't build navigation for docsets in isolation +3. **URL rigidity** - URLs computed at construction, can't change prefixes +4. **Mixed concerns** - Configuration, navigation, and content generation entangled +5. **No type safety** - Weak typing, no use of covariance + +### After: Two-Phase Isolated Navigation (current branch) + +``` +Phase 1: Isolated Building (per docset) +┌──────────────────────────────────────────────────┐ +│ DocumentationSetNavigation │ +│ implements: IRootNavigationItem │ +│ INavigationHomeProvider │ +│ INavigationHomeAccessor │ +└──────────────────────────────────────────────────┘ + │ + ┌───────────┴───────────────────┐ + │ │ +TableOfContentsNavigation FolderNavigation + │ │ +FileNavigationLeaf VirtualFileNavigation + +Phase 2: Assembly (cross-docset) +┌──────────────────────────────────────────────────┐ +│ SiteNavigation │ +│ - Combines multiple DocumentationSets │ +│ - Updates HomeProvider for re-homing │ +│ - Resolves cross-links │ +└──────────────────────────────────────────────────┘ +``` + +**Benefits:** +1. **Loose coupling** - Navigation builds independently from content +2. **Reusability** - Each docset navigation is self-contained +3. **Dynamic URLs** - URLs computed on-demand via Home Provider pattern +4. **Separation** - Configuration → Navigation → Assembly → Rendering (distinct phases) +5. **Type safety** - Covariant interfaces enable compile-time guarantees + +--- + +## Type System Improvements: Covariance + +### Key Commits +- `234055bb` - Fix remaining covariance issues +- `00f65d36` - Preserve covariance of interface + +### The Problem with Old Interfaces + +**Issues in main branch:** + +1. **`TIndex Index`** - Invariant type parameter breaks polymorphism + - Can't assign `INodeNavigationItem` to `INodeNavigationItem` + +2. **`int Depth`** - Stored at every node + - Redundant (calculable from tree structure) + - Wastes memory + +3. **`bool IsCrossLink`** - Type discrimination via property + - Runtime checks instead of compile-time type safety + +### The Solution: Covariant Interfaces + +**Key Changes:** + +1. **Added `out` keyword** - `INodeNavigationItem` + - Enables upcasting to base types + - Polymorphic navigation trees now possible + +2. **Wrapped Index** - `ILeafNavigationItem Index` instead of `TIndex Index` + - Interface wrapper enables covariance + - Access model via `.Index.Model` + +3. **Removed `Depth`** - Calculate on-demand via extension method + +4. **Removed `IsCrossLink`** - Use pattern matching (`item is CrossLinkNavigationLeaf`) + +**Why This Matters:** + +- **Type Safety** - Compiler catches mismatches at compile time +- **Flexibility** - Work with navigation trees polymorphically +- **Memory** - No stored depth/URLs at every node +- **Cleaner APIs** - Better developer experience + +### Interface Hierarchy + +``` +INavigationModel (marker interface) + ↓ +INavigationItem + ├─→ ILeafNavigationItem + │ └─ TModel Model { get; } + │ + └─→ INodeNavigationItem + ├─ ILeafNavigationItem Index { get; } + ├─ IReadOnlyCollection NavigationItems { get; } + │ + └─→ IRootNavigationItem + ├─ bool IsUsingNavigationDropdown { get; } + ├─ Uri Identifier { get; } + └─ void SetNavigationItems(...) // Via IAssignableChildrenNavigation +``` + +**Note the `out` keywords** - These enable covariance: +- `ILeafNavigationItem` - Can upcast to less-derived types +- `INodeNavigationItem` - Both type parameters are covariant + +--- + +## The Home Provider Pattern + +### The Problem: URL Re-homing + +**Scenario:** Combine multiple repos with different URL prefixes +- `elasticsearch` repo → `/elasticsearch/docs/intro/` +- `kibana` repo → `/kibana/docs/intro/` + +**Old approach (main branch):** +- URLs baked in at construction time +- Changing prefixes requires **rebuilding entire tree** - O(n) +- Must allocate new objects (high memory cost) + +### The Solution: Home Provider Pattern + +**Key Interfaces:** + +- **`INavigationHomeProvider`** - Provides URL context (PathPrefix, NavigationRoot, Id) +- **`INavigationHomeAccessor`** - Allows nodes to access/update their provider + +**How It Works:** + +1. **Indirection** - Nodes reference a provider instead of storing URL info +2. **Dynamic URLs** - Computed on-demand from current provider's PathPrefix +3. **Caching** - URLs cached per provider ID, invalidated on provider change +4. **O(1) Re-homing** - Change provider → all descendants automatically use new prefix + +**Re-homing Example:** + +```csharp +// Single assignment updates entire subtree +elasticsearchNav.HomeProvider = new NavigationHomeProvider("/elasticsearch", siteNav); + +// All URLs in subtree now use /elasticsearch/ prefix - O(1)! +``` + +**Benefits:** + +- **Performance** - O(1) re-homing vs O(n) tree rebuilding +- **Memory** - No tree cloning, single reference update +- **Flexibility** - Re-home subtrees multiple times +- **Lazy Evaluation** - URLs computed when accessed +- **Caching** - Per-provider caching avoids redundant calculations + +--- + +## Two-Phase Navigation Building + +### Phase 1: Isolated Building + +Each documentation set builds navigation **independently**: + +**Process:** +1. Parse `_docset.yml` via `DocumentationSetFile.LoadAndResolve()` +2. Build `DocumentationSetNavigation` with no prefix +3. Result is self-contained, reusable navigation tree + +**Properties:** +- **Independent** - No knowledge of other docsets +- **Cacheable** - Can serialize and load separately +- **Testable** - Each docset tests in isolation +- **Parallel** - Build multiple docsets concurrently + +### Phase 2: Assembly + +Combine multiple docsets into unified site: + +**Process:** +1. Parse `navigation.yml` listing all docsets +2. Load each docset's navigation +3. Re-home via `docSetNav.HomeProvider = new NavigationHomeProvider(pathPrefix, siteNav)` +4. Add to `SiteNavigation` + +**Result:** +- Each docset has custom URL prefix +- Cross-docset references resolved +- Phantom navigation for external references +- All navigation trees composed into single hierarchy + +--- + +## Detailed Technical Improvements + +### 1. Configuration Layer Refactor + +**Key Changes:** + +- **`DocumentationSetFile`** (726 lines) - Pure data class for YAML configuration + - `LoadAndResolve()` method parses YAML and resolves all file paths eagerly + - Returns immutable, validated configuration objects + - No navigation building logic + +- **`DocumentationSetNavigation`** (429 lines) - Converts configuration to navigation tree + - Takes `DocumentationSetFile` as input + - Pure transformation, no YAML parsing + - Separated from content generation + +- **`ConfigurationFile`** - Simplified from ~350 to ~140 lines + - Only handles product/version substitutions + - No TOC parsing or navigation building + - Receives pre-parsed `DocumentationSetFile` + +**Benefits:** +- **Testability** - Can test YAML parsing independently from navigation +- **Reusability** - Configuration can be cached/serialized +- **Clarity** - Single responsibility per class + +### 2. Node Type Hierarchy + +#### Navigation Node Types + +**Root Nodes (can be re-homed):** + +```csharp +// Site-level navigation +SiteNavigation + : IRootNavigationItem + +// Docset-level navigation +DocumentationSetNavigation + : IRootNavigationItem + , INavigationHomeProvider + , INavigationHomeAccessor + +// TOC-level navigation (nested docsets) +TableOfContentsNavigation + : IRootNavigationItem + , INavigationHomeAccessor +``` + +**Intermediate Nodes:** + +```csharp +// Folders +FolderNavigation + : INodeNavigationItem + , INavigationHomeAccessor + +// Files with children (e.g., setup/index.md with setup/advanced.md) +VirtualFileNavigation + : IRootNavigationItem + , INavigationHomeAccessor +``` + +**Leaf Nodes:** + +```csharp +// Regular file +FileNavigationLeaf + : ILeafNavigationItem + +// Cross-link to another docset +CrossLinkNavigationLeaf + : ILeafNavigationItem +``` + +#### Type Relationships + +``` +IRootNavigationItem + └─ Can be top of a navigation tree + └─ Can have children assigned via SetNavigationItems() + +INodeNavigationItem + └─ Has children + └─ Has an Index (landing page) + +ILeafNavigationItem + └─ Terminal node + └─ Has a Model (content) + +INavigationHomeProvider + └─ Provides URL context (PathPrefix, NavigationRoot) + +INavigationHomeAccessor + └─ Can access/change HomeProvider + └─ Enables re-homing +``` + +### 3. Path Resolution Enables Context-Aware URLs + +**The Problem in main branch:** + +URL building required runtime path manipulation to handle: +- Isolated builds: URLs relative to documentation set +- Assembler builds: URLs relative to declaring TOC + +**The Solution: Eager Path Resolution** + +`DocumentationSetFile.LoadAndResolve()` resolves **two path representations** during config load: + +1. **`PathRelativeToDocumentationSet`** - Full path from docset root + - Used in isolated builds + - Example: `guides/api/search.md` + +2. **`PathRelativeToContainer`** - Path relative to declaring TOC + - Used in assembler builds + - Example: `api/search.md` (in `guides/toc.yml`) + +**URL Building:** + +Simple conditional chooses pre-resolved path based on context: +```csharp +var relativePath = isAssemblerBuild + ? PathRelativeToContainer + : PathRelativeToDocumentationSet; +``` + +**Benefits:** +- **No runtime manipulation** - Paths resolved once at config load +- **Performance** - No string operations during URL building +- **Clarity** - Simple conditional instead of complex path logic + +### 4. Diagnostic System: Suppressible Hints + +Navigation now emits **hints** (not errors) for potentially problematic patterns: + +**Hint Types:** + +1. **`DeepLinkingVirtualFile`** - File with deep path that has children + ```yaml + # Emits hint - use 'folder' instead + - file: guides/api/overview.md + children: [...] + ``` + Virtual files intended for sibling grouping, not nested structures. + +2. **`FolderFileNameMismatch`** - Folder+file with mismatched names + ```yaml + # Emits hint - file should be api.md or index.md + - folder: api + file: overview.md + ``` + +**Suppressing Hints:** + +```yaml +# _docset.yml +suppress_hints: + - deep_linking_virtual_file + - folder_file_name_mismatch +``` + +**Benefits:** +- Guides authors toward best practices without blocking builds +- Teams can suppress for legacy docs or during migration + +### 5. Test Infrastructure + +**Total: 4,699 lines of new test code across 3 projects** + +#### Navigation.Tests/Isolation/ (2,383 lines) + +**Purpose:** Test isolated navigation building (single docset) + +**Coverage:** +- Navigation node construction and initialization +- URL generation with different HomeProvider contexts +- File reference validation and error handling +- Tree structure correctness and parent-child relationships +- Folder+file combination handling +- Hint emission for virtual files +- Testing against real physical docset configurations + +#### Navigation.Tests/Assembler/ (1,528 lines) + +**Purpose:** Test cross-docset assembly and site navigation + +**Coverage:** +- HomeProvider re-homing (O(1) URL prefix changes) +- Multi-docset scenarios with cross-links +- URI identifier resolution across docsets +- Site-wide docset integration +- Phantom navigation support +- URL prefix handling in assembly context + +#### Elastic.Documentation.Configuration.Tests/ (1,153 lines) + +**Purpose:** Test configuration parsing and validation + +**Coverage:** +- YAML deserialization to typed objects +- `LoadAndResolve` path resolution logic +- Dual path representation (PathRelativeToDocumentationSet vs Container) +- Configuration validation rules +- Loading real physical docset configurations + +--- + +## Conclusion + +This refactor represents a **fundamental architectural improvement** to the docs-builder navigation system: + +1. **Type Safety** - Covariant interfaces catch errors at compile time +2. **Performance** - O(1) re-homing via Home Provider pattern +3. **Modularity** - Two-phase building enables caching and parallelization +4. **Testability** - 4,699 lines of new tests with >80% coverage +5. **Maintainability** - Clear separation of concerns, extensive documentation + +The investment in this refactor pays dividends in: +- **Developer experience** - Cleaner APIs, better tooling support +- **Build performance** - Parallel docset building, intelligent caching +- **Flexibility** - Easy to add new navigation types and behaviors +- **Reliability** - Comprehensive tests catch regressions + +--- + +**For Questions:** +- Review the extensive documentation in `/docs/development/navigation/` +- Check test examples in `/tests/Navigation.Tests/` +- Read the inline comments in core navigation classes + +**Next Steps:** +1. Review the covariance changes in `INavigationItem.cs` +2. Understand the Home Provider pattern in `INavigationHomeProvider.cs` +3. Trace a navigation build in `DocumentationSetNavigation.cs` +4. Run the tests to see the system in action diff --git a/NAVIGATION_REFACTOR_SLIDES.md b/NAVIGATION_REFACTOR_SLIDES.md new file mode 100644 index 000000000..0849592c3 --- /dev/null +++ b/NAVIGATION_REFACTOR_SLIDES.md @@ -0,0 +1,313 @@ +# Navigation Refactor - Walkthrough +**Branch:** `refactor/navigation` | **Date:** 2025-11-04 + +--- + +## Slide 1: Overview + +**Impact: 169 files changed** +- 13,872 additions +- 2,756 deletions +- 4,699 new test lines + +**Key Achievements** +- Type-safe covariant interfaces +- O(1) URL re-homing via Home Provider pattern +- Two-phase building (isolated + assembly) +- Complete separation of concerns +- Extensive test coverage + +--- + +## Slide 2: Architecture - Before (main branch) + +**Problems with Monolithic Navigation** + +- Tight coupling - navigation built during markdown parsing +- No reusability - can't build docsets in isolation +- URL rigidity - URLs baked in at construction +- Mixed concerns - config + navigation + content entangled +- Weak typing - no covariance support + +--- + +## Slide 3: Architecture - After (current branch) + +**Two-Phase Isolated Navigation** + +**Phase 1: Isolated Building** +- Each docset builds independently +- Self-contained navigation trees +- Cacheable, testable, parallelizable + +**Phase 2: Assembly** +- Combine multiple docsets +- O(1) URL prefix updates +- Cross-docset reference resolution + +--- + +## Slide 4: Type System - Covariance + +**Problems in main branch:** +- `TIndex Index` - invariant, breaks polymorphism +- `int Depth` - stored at every node, wasteful +- `bool IsCrossLink` - runtime checks vs compile-time safety + +**Solution:** +- Added `out` keyword for covariance +- Wrapped Index in `ILeafNavigationItem` +- Removed Depth (calculate on-demand) +- Removed IsCrossLink (use pattern matching) + +--- + +## Slide 5: Interface Hierarchy + +``` +INavigationModel (marker) + ↓ +INavigationItem + ├─→ ILeafNavigationItem + │ └─ TModel Model + │ + └─→ INodeNavigationItem + ├─ ILeafNavigationItem Index + ├─ IReadOnlyCollection NavigationItems + │ + └─→ IRootNavigationItem + ├─ bool IsUsingNavigationDropdown + ├─ Uri Identifier + └─ IAssignableChildrenNavigation +``` + +**Note:** `out` keywords enable upcasting to base types + +--- + +## Slide 6: Home Provider Pattern - The Problem + +**Scenario:** Combine repos with different URL prefixes +- `elasticsearch` repo → `/elasticsearch/docs/intro/` +- `kibana` repo → `/kibana/docs/intro/` + +**Old approach (main branch):** +- URLs baked in at construction +- Changing prefixes = rebuild entire tree (O(n)) +- High memory cost + +--- + +## Slide 7: Home Provider Pattern - The Solution + +**Key Interfaces:** +- `INavigationHomeProvider` - provides URL context +- `INavigationHomeAccessor` - nodes access/update provider + +**How it works:** +1. Nodes reference provider (indirection) +2. URLs computed on-demand from PathPrefix +3. URLs cached per provider ID +4. **O(1) re-homing** - single assignment updates entire subtree + +**Example:** +```csharp +elasticsearchNav.HomeProvider = + new NavigationHomeProvider("/elasticsearch", siteNav); +// All URLs now use /elasticsearch/ prefix - O(1)! +``` + +--- + +## Slide 8: Two-Phase Building - Phase 1 + +**Phase 1: Isolated Building** + +**Process:** +1. Parse `_docset.yml` via `LoadAndResolve()` +2. Build `DocumentationSetNavigation` +3. Result: self-contained navigation tree + +**Properties:** +- Independent (no knowledge of other docsets) +- Cacheable (serialize/load separately) +- Testable (isolated testing) +- Parallel (build concurrently) + +--- + +## Slide 9: Two-Phase Building - Phase 2 + +**Phase 2: Assembly** + +**Process:** +1. Parse `navigation.yml` listing docsets +2. Load each docset's navigation +3. Re-home: `docSetNav.HomeProvider = new NavigationHomeProvider(...)` +4. Add to `SiteNavigation` + +**Result:** +- Each docset has custom URL prefix +- Cross-docset references resolved +- Phantom navigation for external refs +- Single unified hierarchy + +--- + +## Slide 10: Configuration Layer Refactor + +**Key Changes:** + +**DocumentationSetFile** (726 lines) +- Pure data class for YAML config +- `LoadAndResolve()` parses and resolves paths +- Immutable, validated configuration + +**DocumentationSetNavigation** (429 lines) +- Converts config → navigation tree +- Pure transformation, no YAML parsing + +**ConfigurationFile** (140 lines, was ~350) +- Only product/version substitutions +- No TOC parsing or navigation building + +--- + +## Slide 11: Node Type Hierarchy + +**Root Nodes (can be re-homed):** +- `SiteNavigation` +- `DocumentationSetNavigation` +- `TableOfContentsNavigation` + +**Intermediate Nodes:** +- `FolderNavigation` +- `VirtualFileNavigation` + +**Leaf Nodes:** +- `FileNavigationLeaf` +- `CrossLinkNavigationLeaf` + +All implement `INavigationHomeAccessor` for re-homing + +--- + +## Slide 12: Path Resolution + +**The Problem (main branch):** +- Runtime path manipulation for URL building +- Different logic for isolated vs assembler builds + +**The Solution:** +- `LoadAndResolve()` creates **two path representations**: + 1. `PathRelativeToDocumentationSet` (isolated builds) + 2. `PathRelativeToContainer` (assembler builds) + +**URL Building:** +```csharp +var relativePath = isAssemblerBuild + ? PathRelativeToContainer + : PathRelativeToDocumentationSet; +``` + +**Benefits:** No runtime manipulation, simple conditional + +--- + +## Slide 13: Diagnostic System - Hints + +**New Suppressible Hints:** + +**1. DeepLinkingVirtualFile** +- File with deep path that has children +- Suggests using `folder` syntax instead +- Virtual files for sibling grouping, not nesting + +**2. FolderFileNameMismatch** +- Folder+file with mismatched names +- Best practice: file = folder name or index.md + +**Suppressing:** +```yaml +suppress_hints: + - deep_linking_virtual_file + - folder_file_name_mismatch +``` + +--- + +## Slide 14: Test Infrastructure + +**Total: 4,699 lines across 3 projects** + +**Navigation.Tests/Isolation** (2,383 lines) +- Node construction, URL generation +- File validation, tree structure +- Hint emission, physical docset testing + +**Navigation.Tests/Assembler** (1,528 lines) +- HomeProvider re-homing (O(1) changes) +- Multi-docset scenarios with cross-links +- Site-wide integration, phantom support + +**Configuration.Tests** (1,153 lines) +- YAML deserialization, path resolution +- Dual path representation validation +- Real docset configuration testing + +--- + +## Slide 15: Key Benefits + +**Type Safety** +- Covariant interfaces catch errors at compile time +- Polymorphic navigation trees + +**Performance** +- O(1) re-homing vs O(n) tree rebuilding +- Lazy URL evaluation with caching + +**Modularity** +- Two-phase building enables caching +- Parallel docset processing + +**Testability** +- 4,699 lines of tests +- >80% coverage + +**Maintainability** +- Clear separation of concerns +- Extensive documentation in `/docs/development/navigation/` + +--- + +## Slide 16: Summary + +**Fundamental Architectural Improvements:** + +1. **Covariant interfaces** - type-safe polymorphism +2. **Home Provider pattern** - O(1) URL re-homing +3. **Two-phase building** - isolated + assembly +4. **Path resolution** - eager resolution, context-aware +5. **Suppressible hints** - guidance without blocking +6. **Comprehensive tests** - 4,699 new test lines + +**Documentation:** `/docs/development/navigation/` + +--- + +## Slide 17: Questions? + +**Documentation Resources:** +- `/docs/development/navigation/navigation.md` - Overview +- `/docs/development/navigation/home-provider-architecture.md` - Deep dive +- `/docs/development/navigation/functional-principles.md` - Design principles + +**Test Examples:** +- `/tests/Navigation.Tests/` - Comprehensive test suite + +**Next Steps:** +1. Review covariance changes in `INavigationItem.cs` +2. Understand Home Provider pattern +3. Trace navigation build in `DocumentationSetNavigation.cs` +4. Run tests to see system in action diff --git a/README.md b/README.md index e883e2dac..19713a1aa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# docs-builder +#docs-builder [![ci](https://github.com/elastic/docs-builder/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/elastic/docs-builder/actions/workflows/ci.yml) diff --git a/build/build.fsproj b/build/build.fsproj index dfe992edf..1f1caa585 100644 --- a/build/build.fsproj +++ b/build/build.fsproj @@ -14,10 +14,6 @@ - - - - diff --git a/config/navigation.yml b/config/navigation.yml index 5e96cb1da..c7dc93d86 100644 --- a/config/navigation.yml +++ b/config/navigation.yml @@ -8,6 +8,8 @@ phantoms: - toc: elasticsearch://reference - toc: docs-content://release-notes + - toc: docs-content://release-notes/elasticsearch-clients + - toc: docs-content://release-notes/apm-agents - toc: docs-content:// - toc: cloud://release-notes diff --git a/docs-builder.sln b/docs-builder.sln index a9013aafc..3af1be8a8 100644 --- a/docs-builder.sln +++ b/docs-builder.sln @@ -145,7 +145,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Isola EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Api.Infrastructure.Tests", "tests\Elastic.Documentation.Api.Infrastructure.Tests\Elastic.Documentation.Api.Infrastructure.Tests.csproj", "{77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Configuration.Tests", "tests\Elastic.Documentation.Configuration.Tests\Elastic.Documentation.Configuration.Tests.csproj", "{102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Navigation", "src\Elastic.Documentation.Navigation\Elastic.Documentation.Navigation.csproj", "{2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Configuration.Tests", "tests\Elastic.Documentation.Configuration.Tests\Elastic.Documentation.Configuration.Tests.csproj", "{A8952020-F843-41B6-B456-BE95AFEBBBCA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Navigation.Tests", "tests\Navigation.Tests\Navigation.Tests.csproj", "{E9514A33-3DC1-48B5-9131-FDBDD492A833}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -170,17 +174,11 @@ Global {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|x86.ActiveCfg = Release|Any CPU {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|x86.Build.0 = Release|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|Any CPU.Build.0 = Debug|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x64.ActiveCfg = Debug|Any CPU - {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x64.Build.0 = Debug|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x86.ActiveCfg = Debug|Any CPU - {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x86.Build.0 = Debug|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|Any CPU.Build.0 = Release|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x64.ActiveCfg = Release|Any CPU - {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x64.Build.0 = Release|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x86.ActiveCfg = Release|Any CPU - {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x86.Build.0 = Release|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|Any CPU.Build.0 = Debug|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -193,18 +191,6 @@ Global {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x64.Build.0 = Release|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x86.ActiveCfg = Release|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x86.Build.0 = Release|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x64.ActiveCfg = Debug|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x64.Build.0 = Debug|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x86.ActiveCfg = Debug|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x86.Build.0 = Debug|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|Any CPU.Build.0 = Release|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x64.ActiveCfg = Release|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x64.Build.0 = Release|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x86.ActiveCfg = Release|Any CPU - {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x86.Build.0 = Release|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|Any CPU.Build.0 = Debug|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -218,16 +204,16 @@ Global {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|x86.ActiveCfg = Release|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|x86.Build.0 = Release|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|Any CPU.Build.0 = Debug|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x64.ActiveCfg = Debug|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x64.Build.0 = Debug|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x86.ActiveCfg = Debug|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x86.Build.0 = Debug|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|Any CPU.ActiveCfg = Release|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x64.ActiveCfg = Release|Any CPU - {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x64.Build.0 = Release|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x86.ActiveCfg = Release|Any CPU - {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x86.Build.0 = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x64.ActiveCfg = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x86.ActiveCfg = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|Any CPU.Build.0 = Debug|Any CPU {018F959E-824B-4664-B345-066784478D24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {018F959E-824B-4664-B345-066784478D24}.Debug|Any CPU.Build.0 = Debug|Any CPU {018F959E-824B-4664-B345-066784478D24}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -264,6 +250,10 @@ Global {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x64.Build.0 = Release|Any CPU {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x86.ActiveCfg = Release|Any CPU {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x86.Build.0 = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x64.ActiveCfg = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x64.Build.0 = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x86.ActiveCfg = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x86.Build.0 = Release|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|Any CPU.Build.0 = Debug|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -492,18 +482,85 @@ Global {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Release|x64.Build.0 = Release|Any CPU {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Release|x86.ActiveCfg = Release|Any CPU {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Release|x86.Build.0 = Release|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Debug|x64.ActiveCfg = Debug|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Debug|x64.Build.0 = Debug|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Debug|x86.ActiveCfg = Debug|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Debug|x86.Build.0 = Debug|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Release|Any CPU.Build.0 = Release|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Release|x64.ActiveCfg = Release|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Release|x64.Build.0 = Release|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Release|x86.ActiveCfg = Release|Any CPU - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C}.Release|x86.Build.0 = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x64.ActiveCfg = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x64.Build.0 = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x86.ActiveCfg = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x86.Build.0 = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|Any CPU.Build.0 = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x64.ActiveCfg = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x64.Build.0 = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x86.ActiveCfg = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x86.Build.0 = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x64.ActiveCfg = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x64.Build.0 = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x86.ActiveCfg = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x86.Build.0 = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|Any CPU.Build.0 = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x64.ActiveCfg = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x64.Build.0 = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x86.ActiveCfg = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x86.Build.0 = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x64.ActiveCfg = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x64.Build.0 = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x86.ActiveCfg = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x86.Build.0 = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|Any CPU.Build.0 = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x64.ActiveCfg = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x64.Build.0 = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x86.ActiveCfg = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x64.ActiveCfg = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x64.Build.0 = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x86.ActiveCfg = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x86.Build.0 = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x64.ActiveCfg = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x64.Build.0 = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x86.ActiveCfg = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Debug|x86.Build.0 = Debug|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|Any CPU.Build.0 = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x64.ActiveCfg = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x64.Build.0 = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x86.ActiveCfg = Release|Any CPU + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300}.Release|x86.Build.0 = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x64.ActiveCfg = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x64.Build.0 = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x86.ActiveCfg = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Debug|x86.Build.0 = Debug|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|Any CPU.Build.0 = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x64.ActiveCfg = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x64.Build.0 = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x86.ActiveCfg = Release|Any CPU + {A8952020-F843-41B6-B456-BE95AFEBBBCA}.Release|x86.Build.0 = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x64.ActiveCfg = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x64.Build.0 = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x86.ActiveCfg = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Debug|x86.Build.0 = Debug|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|Any CPU.Build.0 = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x64.ActiveCfg = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x64.Build.0 = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x86.ActiveCfg = Release|Any CPU + {E9514A33-3DC1-48B5-9131-FDBDD492A833}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -516,9 +573,11 @@ Global {A2A34BBC-CB5E-4100-9529-A12B6ECB769C} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {018F959E-824B-4664-B345-066784478D24} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166} = {059E787F-85C1-43BE-9DD6-CE319E106383} + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166} = {059E787F-85C1-43BE-9DD6-CE319E106383} {6E2ED6CC-AFC1-4E58-965D-6AEC500EBB46} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {6554F917-73CE-4B3D-9101-F28EAA762C6B} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {C559D52D-100B-4B2B-BE87-2344D835761D} = {4894063D-0DEF-4B7E-97D0-0D0A5B85C608} + {C559D52D-100B-4B2B-BE87-2344D835761D} = {4894063D-0DEF-4B7E-97D0-0D0A5B85C608} {4894063D-0DEF-4B7E-97D0-0D0A5B85C608} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {BB789671-B262-43DD-91DB-39F9186B8257} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {09CE30F6-013A-49ED-B3D6-60AFA84682AC} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} @@ -545,6 +604,8 @@ Global {153FC4AD-F5B0-4100-990E-0987C86DBF01} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {AABD3EF7-8C86-4981-B1D2-B1F786F33069} = {7AACA67B-3C56-4C7C-9891-558589FC52DB} {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} - {102570C0-1FAD-4DBE-8C7D-234E71BDFF1C} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} + {2906C5A4-6EF2-47A4-8CC8-18D3A53AC300} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} + {A8952020-F843-41B6-B456-BE95AFEBBBCA} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} + {E9514A33-3DC1-48B5-9131-FDBDD492A833} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} EndGlobalSection EndGlobal diff --git a/docs/_docset.yml b/docs/_docset.yml index d53c3cbe4..bcc68e978 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -176,6 +176,8 @@ toc: - file: index.md - file: req.md - folder: nested + children: + - file: index.md - file: cross-links.md children: - title: "Getting Started Guide" diff --git a/docs/development/navigation/assembler-process.md b/docs/development/navigation/assembler-process.md new file mode 100644 index 000000000..0a36571b6 --- /dev/null +++ b/docs/development/navigation/assembler-process.md @@ -0,0 +1,304 @@ +# Assembler Process + +The assembler combines multiple documentation repositories into a unified site with custom URL prefixes. + +> **Prerequisites:** Read [Functional Principles #4](functional-principles.md#4.-navigation-roots-can-be-re-homed) and [Home Provider Architecture](home-provider-architecture.md) first to understand re-homing. + +## The Challenge + +Multiple repositories need to appear as one site: +- `elastic-docs` with `/api/` and `/guides/` +- Assembled site needs `/elasticsearch/api/` and `/elasticsearch/guides/` +- Same content, different URLs, no rebuilding + +## The Solution + +Four-phase process: + +### Phase 1: Build with AssemblerBuild Flag + +```csharp +public AssemblerDocumentationSet( + ILoggerFactory logFactory, + AssembleContext context, + Checkout checkout, + ICrossLinkResolver crossLinkResolver, + IConfigurationContext configurationContext, + IReadOnlySet availableExporters) +{ + // For each repository: + // 1. Load and resolve docset.yml + var buildContext = new BuildContext(...) + { + AssemblerBuild = true // ← CRITICAL! + }; + + // 2. Build DocumentationSetNavigation with assembler context + DocumentationSet = new DocumentationSet(buildContext, logFactory, crossLinkResolver); +} +``` + +**Why `AssemblerBuild = true` matters:** +```csharp +// DocumentationSetNavigation constructor when creating TOCs: +var assemblerBuild = context.AssemblerBuild; + +var tocHomeProvider = assemblerBuild + ? new NavigationHomeProvider(...) // Create NEW scope + : parentHomeProvider; // Inherit parent's scope + +// Result: Each TOC gets its own HomeProvider instance +``` + +**Without this flag:** +``` +DocumentationSetNavigation + └─ TableOfContentsNavigation (api) + HomeProvider: INHERITED ← shares parent's provider +``` +Can't re-home independently. + +**With this flag:** +``` +DocumentationSetNavigation + └─ TableOfContentsNavigation (api) + HomeProvider: NEW INSTANCE ← own provider! +``` +Can re-home independently! + +### Phase 2: Load navigation.yml + +```yaml +toc: + - toc: elastic-docs://api + path_prefix: elasticsearch/api + - toc: elastic-docs://guides + path_prefix: elasticsearch/guides +``` + +Defines where each TOC appears in the site. + +### Phase 3: Create SiteNavigation + +```csharp +public SiteNavigation( + SiteNavigationFile siteNavigationFile, + IDocumentationContext context, + IReadOnlyCollection documentationSetNavigations, + string? sitePrefix) +{ + // 1. Initialize SiteNavigation as root + NavigationRoot = this; + + // 2. Collect all docset/TOC nodes into dictionary + foreach (var setNavigation in documentationSetNavigations) + { + foreach (var (identifier, node) in setNavigation.TableOfContentNodes) + _nodes.TryAdd(identifier, node); + } + + // 3. Process each navigation.yml reference + foreach (var tocRef in siteNavigationFile.TableOfContents) + { + var navItem = CreateSiteTableOfContentsNavigation(tocRef, index++, context, this, null); + if (navItem != null) + items.Add(navItem); + } +} +``` + +### Phase 4: Re-home Each Reference + +For each entry in `navigation.yml`: + +```csharp +private INavigationItem? CreateSiteTableOfContentsNavigation( + SiteTableOfContentsRef tocRef, + int index, + IDocumentationContext context, + INodeNavigationItem parent, + IRootNavigationItem? root) +{ + // 1. Calculate final path_prefix + // 2. Look up node by identifier (elastic-docs://api) + // 3. Replace node's HomeProvider ← THE MAGIC! ⚡ + // 4. Update parent/index + // 5. Process children (skip nested root nodes) +} +``` + +**The critical line:** +```csharp +private INavigationItem? CreateSiteTableOfContentsNavigation(...) +{ + // ... + homeAccessor.HomeProvider = new NavigationHomeProvider(pathPrefix, siteRoot); +} +``` + +This single assignment updates all descendant URLs instantly (O(1)). + +## How It Works: Example + +**Input: Built with AssemblerBuild = true** +``` +elastic-docs:// + HomeProvider: self + └─ elastic-docs://api + HomeProvider: Instance A (PathPrefix = "") + └─ api/rest.md → URL: /api/rest/ + └─ elastic-docs://guides + HomeProvider: Instance B (PathPrefix = "") + └─ guides/start.md → URL: /guides/start/ +``` + +**navigation.yml:** +```yaml +- toc: elastic-docs://api + path_prefix: elasticsearch/api +- toc: elastic-docs://guides + path_prefix: elasticsearch/guides +``` + +**After Re-homing:** +``` +SiteNavigation + └─ elastic-docs://api + HomeProvider: NEW (PathPrefix = "elasticsearch/api") ← Replaced! + └─ api/rest.md → URL: /elasticsearch/api/rest/ ✓ + └─ elastic-docs://guides + HomeProvider: NEW (PathPrefix = "elasticsearch/guides") ← Replaced! + └─ guides/start.md → URL: /elasticsearch/guides/start/ ✓ +``` + +## Why Separate Scopes Matter + +**Scenario:** Split a docset across the site. + +```yaml +# elastic-docs has both api/ and guides/ TOCs +toc: + - toc: elastic-docs://api + path_prefix: reference/api # Goes here + - toc: elastic-docs://guides + path_prefix: learn/guides # Goes there +``` + +If TOCs shared their parent's provider, both would get the same prefix. Separate providers enable different prefixes from the same repository. + +## Key Architecture Points + +**1. AssemblerBuild Flag Controls Scope Creation** +- True: TOCs create own HomeProvider +- False: TOCs inherit parent's HomeProvider + +**2. HomeProvider is the Re-homing Mechanism** +- URLs calculated from `HomeProvider.PathPrefix` +- Changing provider changes all descendant URLs +- No tree traversal needed + +**3. Root Nodes Can Be Re-homed** +- `DocumentationSetNavigation` - Entire docset +- `TableOfContentsNavigation` - Individual TOC +- Must have own provider (not inherited) + +**4. Non-Root Nodes Inherit** +- `FileNavigationLeaf`, `FolderNavigation`, etc. +- Use parent's HomeProvider +- Re-home automatically when parent re-homed + +## Path Prefix Requirements + +```yaml +# Required +- toc: elastic-docs://api + path_prefix: elasticsearch/api # Must be unique! + +# Exception: narrative repository +- toc: docs-content://guides + # path_prefix defaults to "guides" +``` + +All `path_prefix` values must be unique across the site. + +## Phantom Nodes + +Declared but not included: + +```yaml +phantoms: + - source: plugins:// +``` + +Prevents "undeclared navigation" warnings for excluded content. + +## The Re-homing Flow + +``` +1. Build with AssemblerBuild = true + → TOCs get own HomeProvider + +2. Collect all nodes into dictionary + → Indexed by identifier (elastic-docs://api) + +3. For each navigation.yml entry: + → Look up node + → Replace HomeProvider ← O(1) operation + → All URLs update automatically + +4. Result: Unified site with custom structure +``` + +## What Makes This Fast + +**O(1) Re-homing:** +```csharp +// This updates 10,000 URLs instantly: +node.HomeProvider = new NavigationHomeProvider(newPrefix, newRoot); +``` + +**Why?** +- URLs calculated on-demand from HomeProvider +- Not stored in nodes +- Changing provider = all URLs recalculate next access +- Smart caching invalidates on provider change + +## Common Patterns + +**Pattern 1: Keep docset together** +```yaml +- toc: elastic-docs:// + path_prefix: elasticsearch +``` + +**Pattern 2: Split docset apart** +```yaml +- toc: elastic-docs://api + path_prefix: reference/api +- toc: elastic-docs://guides + path_prefix: learn/guides +``` + +**Pattern 3: Nest docsets** +```yaml +- toc: products + children: + - toc: elasticsearch:// + path_prefix: products/elasticsearch + - toc: kibana:// + path_prefix: products/kibana +``` + +## Summary + +**The assembler enables:** +- Build repositories independently (isolated) +- Combine into unified site (assembled) +- Custom URL structure per site +- Split single docset across multiple sections +- O(1) re-homing (no tree reconstruction) + +**The critical piece:** +`AssemblerBuild = true` causes `TableOfContentsNavigation` to create own `HomeProvider`, enabling independent re-homing of TOCs within a docset. + +Without this, you can only re-home entire docsets. With it, you can split a docset anywhere. diff --git a/docs/development/navigation/first-principles.md b/docs/development/navigation/first-principles.md new file mode 100644 index 000000000..fb5face31 --- /dev/null +++ b/docs/development/navigation/first-principles.md @@ -0,0 +1,45 @@ +# First Principles + +The navigation system is built on two types of principles: + +## [Functional Principles](functional-principles.md) + +These define **what** the navigation system does and **why**: + +1. **Two-Phase Loading** - Separate configuration resolution from navigation construction +2. **Single Documentation Source** - All paths relative to docset root +3. **URL Building is Dynamic** - URLs calculated on-demand, not stored +4. **Navigation Roots Can Be Re-homed** - O(1) URL prefix changes +5. **Navigation Scope via HomeProvider** - Scoped URL calculation contexts +6. **Index Files Determine URLs** - Folders and indexes share URLs +7. **File Structure Mirrors Navigation** - Predictable, maintainable structure +8. **Acyclic Graph Structure** - Tree with no cycles, unique URLs +9. **Phantom Nodes** - Acknowledge content without including it + +[Read Functional Principles →](functional-principles.md) + +## [Technical Principles](technical-principles.md) + +These define **how** the navigation system is implemented: + +1. **Generic Type System** - Covariance enables static typing without runtime casts +2. **Provider Pattern** - Decouples URL calculation from tree structure +3. **Lazy URL Calculation** - Smart caching with automatic invalidation + +[Read Technical Principles →](technical-principles.md) + +--- + +## Quick Reference + +**For understanding architecture:** Start with [Functional Principles](functional-principles.md) + +**For implementation details:** See [Technical Principles](technical-principles.md) + +**For visual examples:** See [Visual Walkthrough](visual-walkthrough.md) + +**For specific topics:** +- How assembly works: [Assembler Process](assembler-process.md) +- How re-homing works: [Home Provider Architecture](home-provider-architecture.md) +- Two-phase approach: [Two-Phase Loading](two-phase-loading.md) +- Node reference: [Node Types](node-types.md) diff --git a/docs/development/navigation/functional-principles.md b/docs/development/navigation/functional-principles.md new file mode 100644 index 000000000..fc15b37a0 --- /dev/null +++ b/docs/development/navigation/functional-principles.md @@ -0,0 +1,184 @@ +# Functional Principles + +These principles define what the navigation system does and why. + +> **Also see:** [Technical Principles](technical-principles.md) for implementation details. + +## 1. Two-Phase Loading + +Navigation construction follows a strict two-phase approach: + +**Phase 1: Configuration Resolution** (`Elastic.Documentation.Configuration`) +- Parse YAML files (`docset.yml`, `toc.yml`, `navigation.yml`) +- Resolve all file references to **full paths** relative to documentation set root +- Validate configuration structure and relationships +- Output: Fully resolved configuration objects with complete file paths + +**Phase 2: Navigation Construction** (`Elastic.Documentation.Navigation`) +- Consume resolved configuration from Phase 1 +- Build navigation tree with **full URLs** +- Create node relationships (parent/child/root) +- Set up home providers for URL calculation +- Output: Complete navigation tree with calculated URLs + +**Why Two Phases?** +- **Separation of Concerns**: Configuration parsing is independent of navigation structure +- **Validation**: Catch file/structure errors before building expensive navigation trees +- **Reusability**: Same configuration can build different navigation structures (isolated vs assembler) +- **Performance**: Resolve file system operations once, reuse for navigation + +> See [Two-Phase Loading](two-phase-loading.md) for detailed explanation. + +## 2. Single Documentation Source + +URLs are always built relative to the documentation set's source directory: +- Files referenced in `docset.yml` are relative to the docset root +- Files referenced in nested `toc.yml` are relative to the toc directory +- During Phase 1, all paths are resolved to be relative to the docset root +- During Phase 2, URLs are calculated from these resolved paths + +**Example:** +``` +docs/ +├── docset.yml # Root +├── index.md +└── api/ + ├── toc.yml # Nested TOC + └── rest.md +``` + +Phase 1 resolves `api/toc.yml` reference to `rest.md` as: `api/rest.md` (relative to docset root) +Phase 2 builds URL as: `/api/rest/` + +## 3. URL Building is Dynamic and Cheap + +URLs are **calculated on-demand**, not stored: +- Nodes don't store their final URL +- URLs are computed from `HomeProvider.PathPrefix` + relative path +- Changing a `HomeProvider` instantly updates all descendant URLs +- No tree traversal needed to update URLs + +**Why Dynamic?** +- **Re-homing**: Same subtree can have different URLs in different contexts +- **Memory Efficient**: Don't store redundant URL strings +- **Consistency**: URLs always reflect current home provider state + +> See [Home Provider Architecture](home-provider-architecture.md) for implementation details. + +## 4. Navigation Roots Can Be Re-homed + +A key design feature that enables assembler builds: +- **Isolated Build**: Each `DocumentationSetNavigation` is its own root +- **Assembler Build**: `SiteNavigation` becomes the root, docsets are "re-homed" +- **Re-homing**: Replace a subtree's `HomeProvider` to change its URL prefix +- **Cheap Operation**: O(1) - just replace the provider reference + +**Example:** +```csharp +// Isolated: URLs start at / +homeProvider.PathPrefix = ""; +// → /api/rest/ + +// Assembled: Re-home to /guide +homeProvider = new NavigationHomeProvider("/guide", siteNav); +// → /guide/api/rest/ +``` + +> See [Assembler Process](assembler-process.md) for how re-homing works in practice. + +## 5. Navigation Scope via HomeProvider + +`INavigationHomeProvider` creates navigation scopes: +- **Provider**: Defines `PathPrefix` and `NavigationRoot` for a scope +- **Accessor**: Children use `INavigationHomeAccessor` to access their scope +- **Inheritance**: Child nodes inherit their parent's accessor +- **Isolation**: Changes to a provider only affect its scope + +**Scope Creators:** +- `DocumentationSetNavigation` - Creates scope for entire docset +- `TableOfContentsNavigation` - Creates scope for TOC subtree (enables re-homing) + +**Scope Consumers:** +- `FileNavigationLeaf` - Uses accessor to calculate URL +- `FolderNavigation` - Passes accessor to children +- `VirtualFileNavigation` - Passes accessor to children + +## 6. Index Files Determine Folder URLs + +Every folder/node navigation has an **Index**: +- Index is either `index.md` or the first file +- The node's URL is the same as its Index's URL +- Children appear "under" the index in navigation +- Index files map to folder paths: `/api/index.md` → `/api/` + +**Why?** +- **Consistent URL Structure**: Folders and their indexes share the same URL +- **Natural Navigation**: Index represents the folder's landing page +- **Hierarchical**: Clear parent-child URL relationships + +## 7. File Structure Should Mirror Navigation + +Best practices for maintainability: +- Navigation structure should follow file system structure +- Avoid deep-linking files from different directories +- Use `folder:` references when possible +- Virtual files should group sibling files, not restructure the tree + +**Rationale:** +- **Discoverability**: Developers can find files by following navigation +- **Predictability**: URL structure matches file structure +- **Maintainability**: Moving files in navigation matches moving them on disk + +## 8. Acyclic Graph Structure + +The navigation forms a **directed acyclic graph (DAG)**: +- **Tree Structure**: Each node has exactly one parent (except root) +- **No Cycles**: Following parent pointers always terminates at root +- **Single Root**: Every node has a `NavigationRoot` pointing to the ultimate ancestor +- **Predictable Traversal**: Tree structure enables efficient queries and traversal + +**Why This Matters:** +- **URL Uniqueness**: Tree structure ensures each file has one canonical URL +- **Consistent Hierarchy**: Clear parent-child relationships for breadcrumbs and navigation +- **Efficient Queries**: Can traverse up (to root) or down (to leaves) without cycle detection +- **Re-homing Safety**: Replacing a subtree's root doesn't create cycles + +**Invariants:** +1. Following `.Parent` chain always reaches root (or null for root) +2. Following `.NavigationRoot` immediately reaches ultimate root +3. No node can be its own ancestor +4. Every node appears exactly once in the tree + +## 9. Phantom Nodes for Incomplete Navigation + +`navigation.yml` can declare phantoms: +```yaml +phantoms: + - source: plugins:// +``` + +**Purpose:** +- Reference nodes that exist but aren't included in site navigation +- Prevent "undeclared navigation" warnings +- Document intentionally excluded content +- Enable validation of cross-links + +--- + +## Key Invariants + +1. **Phase Order**: Configuration must be fully resolved before navigation construction +2. **Path Resolution**: All paths in configuration are relative to docset root after Phase 1 +3. **URL Uniqueness**: Every navigation item must have a unique URL within its site +4. **Root Consistency**: All nodes in a subtree point to the same `NavigationRoot` +5. **Provider Validity**: A node's `HomeProvider` must be an ancestor in the tree +6. **Index Requirement**: All node navigations (folder/toc/docset) must have an Index +7. **Path Prefix Uniqueness**: In assembler builds, all `path_prefix` values must be unique + +## Performance Characteristics + +- **Tree Construction**: O(n) where n = number of files +- **URL Calculation**: O(depth) for first access, O(1) with caching +- **Re-homing**: O(1) - just replace HomeProvider reference +- **Tree Traversal**: O(n) for full tree, but rarely needed +- **Memory**: O(n) for nodes, URLs computed on-demand diff --git a/docs/development/navigation/home-provider-architecture.md b/docs/development/navigation/home-provider-architecture.md new file mode 100644 index 000000000..6c7639bd8 --- /dev/null +++ b/docs/development/navigation/home-provider-architecture.md @@ -0,0 +1,490 @@ +# Home Provider Architecture + +The Home Provider pattern enables O(1) re-homing of navigation subtrees through indirection. + +> **Overview:** For high-level concepts, see [Functional Principles #3-5](functional-principles.md#3.-url-building-is-dynamic-and-cheap). This document explains the implementation. + +## The Problem + +When building assembled documentation sites, we need to: +1. Build navigation for individual repositories in isolation +2. Combine them into a single site with custom URL prefixes +3. Update all URLs in a subtree efficiently + +**Naive approach:** +```csharp +// Traverse entire subtree to update URLs +void UpdateUrlPrefix(INavigationItem root, string newPrefix) +{ + // O(n) - visit every node + foreach (var item in TraverseTree(root)) + { + item.UrlPrefix = newPrefix; + } +} +``` + +**Issues:** +- O(n) traversal for every prefix change +- URL prefix stored at every node +- URLs calculated at construction time +- Changes require tree reconstruction + +## The Solution: Provider Pattern + +Instead of storing URL information at each node, use indirection through a provider: + +```csharp +// The provider defines the URL context for a scope +public interface INavigationHomeProvider +{ + string PathPrefix { get; } + IRootNavigationItem NavigationRoot { get; } + string Id { get; } // For cache invalidation +} + +// Nodes access their provider through an accessor +public interface INavigationHomeAccessor +{ + INavigationHomeProvider HomeProvider { get; set; } +} +``` + +Nodes reference a provider instead of storing URL information: + +```csharp +public class FileNavigationLeaf +{ + private readonly INavigationHomeAccessor _homeAccessor; + + public string Url + { + get + { + // Calculate from current provider + var prefix = _homeAccessor.HomeProvider.PathPrefix; + return $"{prefix}/{_relativePath}/"; + } + } +} +``` + +Re-homing becomes a single assignment: + +```csharp +// Change the provider → all descendants use new prefix +docsetNavigation.HomeProvider = new NavigationHomeProvider("/guide", siteNav); +``` + +## How It Works + +### 1. Scope Creation + +Navigation types that can be re-homed implement `INavigationHomeProvider`: + +```csharp +public class DocumentationSetNavigation + : INavigationHomeProvider, INavigationHomeAccessor +{ + private string _pathPrefix; + + // Provider properties + public string PathPrefix => HomeProvider == this + ? _pathPrefix + : HomeProvider.PathPrefix; + + public IRootNavigationItem<...> NavigationRoot => + HomeProvider == this + ? this + : HomeProvider.NavigationRoot; + + // Accessor property + public INavigationHomeProvider HomeProvider { get; set; } + + // Initially self-referential + public DocumentationSetNavigation(...) + { + _pathPrefix = pathPrefix ?? ""; + HomeProvider = this; + } +} +``` + +### 2. Scope Inheritance + +Child nodes receive their parent's accessor: + +```csharp +// Creating a child node +var fileNav = new FileNavigationLeaf( + model, + fileInfo, + new FileNavigationArgs( + path, + relativePath, + hidden, + index, + parent, + homeAccessor: this // Pass down the accessor + ) +); +``` + +### 3. URL Calculation + +Leaf nodes use the accessor to calculate URLs: + +```csharp +public class FileNavigationLeaf +{ + private readonly FileNavigationArgs _args; + + public string Url + { + get + { + // Get prefix from current provider + var rootUrl = _args.HomeAccessor.HomeProvider.PathPrefix.TrimEnd('/'); + + // Determine path based on context + var relativeToContainer = + _args.HomeAccessor.HomeProvider.NavigationRoot.Parent is SiteNavigation; + + var relativePath = relativeToContainer + ? _args.RelativePathToTableOfContents + : _args.RelativePathToDocumentationSet; + + return BuildUrl(rootUrl, relativePath); + } + } +} +``` + +### 4. Re-homing + +In assembler builds, `SiteNavigation` replaces the provider: + +```csharp +// CreateSiteTableOfContentsNavigation(...): +// Calculate new path prefix for this subtree +var pathPrefix = $"{_sitePrefix}/{tocRef.PathPrefix}".Trim('/'); + +// Create new provider with custom prefix +var newProvider = new NavigationHomeProvider(pathPrefix, root); + +// Replace provider - this is the magic! ⚡ +homeAccessor.HomeProvider = newProvider; + +// All descendants now use the new prefix +``` + +**What happens:** +1. `homeAccessor.HomeProvider` is assigned a new provider +2. Provider has `PathPrefix = "/guide"` and `NavigationRoot = SiteNavigation` +3. Every URL calculation in that subtree now uses the "/guide" prefix +4. No tree traversal needed + +## Example: Isolated to Assembled + +### Isolated Build + +``` +DocumentationSetNavigation (elastic-docs) +├─ HomeProvider: self +├─ PathPrefix: "" +├─ NavigationRoot: self +│ +└─ TableOfContentsNavigation (api/) + ├─ HomeProvider: inherited from parent = DocumentationSetNavigation + ├─ PathPrefix: "" (from provider) + ├─ NavigationRoot: DocumentationSetNavigation (from provider) + │ + └─ FileNavigationLeaf (api/rest.md) + ├─ HomeAccessor.HomeProvider: DocumentationSetNavigation + └─ URL calculation: + prefix = HomeProvider.PathPrefix = "" + path = "api/rest.md" + url = "/api/rest/" +``` + +### Assembler Build - After Re-homing + +``` +SiteNavigation +├─ HomeProvider: self +├─ PathPrefix: "" +├─ NavigationRoot: self +│ +└─ DocumentationSetNavigation (elastic-docs) + ├─ HomeProvider: NEW NavigationHomeProvider("/guide", SiteNavigation) ⚡ + ├─ PathPrefix: "/guide" (from new provider) + ├─ NavigationRoot: SiteNavigation (from new provider) + │ + └─ TableOfContentsNavigation (api/) + ├─ HomeProvider: inherited = new provider ⚡ + ├─ PathPrefix: "/guide" (from new provider) + ├─ NavigationRoot: SiteNavigation (from new provider) + │ + └─ FileNavigationLeaf (api/rest.md) + ├─ HomeAccessor.HomeProvider: new provider ⚡ + └─ URL calculation: + prefix = HomeProvider.PathPrefix = "/guide" + path = "api/rest.md" + url = "/guide/api/rest/" ✨ +``` + +**The re-homing happened at lines marked with ⚡ - a single assignment!** + +## Key Characteristics + +### O(1) Re-homing + +```csharp +// This updates ALL URLs in the subtree - regardless of size! +node.HomeProvider = new NavigationHomeProvider("/new-prefix", newRoot); +``` + +**Time complexity: O(1)** + +This isn't marketing - it's a fact. Whether the subtree has 10 nodes or 10,000 nodes, re-homing takes the same amount of time because it's a single reference assignment. + +**Compare to naive approach:** +- Naive: O(n) - must visit every node +- Provider: O(1) - single assignment + +### Lazy Evaluation + +URLs calculated on-demand: +- Not calculated until accessed +- Always reflects current provider state +- Memory efficient - no stored URL strings + +### Smart Caching + +```csharp +private string? _homeProviderCache; +private string? _urlCache; + +public string Url +{ + get + { + // Check if provider changed + if (_homeProviderCache != null && + _homeProviderCache == _args.HomeAccessor.HomeProvider.Id && + _urlCache != null) + { + return _urlCache; + } + + // Recalculate and cache + _homeProviderCache = _args.HomeAccessor.HomeProvider.Id; + _urlCache = DetermineUrl(); + return _urlCache; + } +} +``` + +Caching strategy: +- First access: O(depth) calculation +- Subsequent accesses: O(1) cache lookup +- Cache invalidates automatically when provider changes (via Id comparison) + +### Scope Isolation + +Each provider creates an isolated scope: +- Changes to one scope don't affect others +- Clear ownership of URL context +- Enables independent re-homing of subtrees + +## Implementation Details + +### Provider Identity + +Each provider has a unique ID for cache invalidation: + +```csharp +public class NavigationHomeProvider : INavigationHomeProvider +{ + public string Id { get; } = Guid.NewGuid().ToString("N"); +} +``` + +When a provider changes, the ID changes, invalidating cached URLs. + +### Accessor vs Provider + +**Provider:** Nodes that create scopes (`DocumentationSetNavigation`, `TableOfContentsNavigation`) + +**Accessor:** All nodes that need to calculate URLs + +Some nodes implement both: +```csharp +public class DocumentationSetNavigation + : INavigationHomeProvider, INavigationHomeAccessor +{ + // Can be a provider AND access a different provider +} +``` + +This dual implementation is what enables re-homing. + +### Passing Accessors Down + +During construction, accessors flow down the tree: + +```csharp +// Parent creates child, passes its accessor +var childNav = ConvertToNavigationItem( + tocItem, + index, + context, + parent: this, + homeAccessor: this // Pass down accessor +); +``` + +Children inherit their parent's accessor, creating a reference chain back to the scope provider. + +### Assembler-Specific Provider Behavior + +In assembler builds, TOCs create isolated providers: + +```csharp +var assemblerBuild = context.AssemblerBuild; + +var isolatedHomeProvider = assemblerBuild + ? new NavigationHomeProvider( + homeAccessor.HomeProvider.PathPrefix, + homeAccessor.HomeProvider.NavigationRoot + ) + : homeAccessor.HomeProvider; +``` + +This ensures TOCs can be re-homed independently during site assembly. + +> See [Assembler Process](assembler-process.md) for details on how this flag controls scope creation. + +## Performance Analysis + +### Memory Usage + +**Per Node:** +- Provider: ~48 bytes (string, reference, guid) +- Accessor: 8 bytes (reference) +- Cache: ~32 bytes (2 strings) - leaf nodes only + +**For 10,000 nodes:** +- Without caching: ~560 KB +- With cached URLs: ~880 KB +- Naive approach (stored URLs): ~1.5 MB+ + +### CPU Usage + +**URL Calculation:** +- Cache hit: O(1) - pointer dereference + string return +- Cache miss: O(depth) - string concatenation + path processing +- Re-homing: O(1) - reference assignment + +**Access Pattern:** +- First access: Calculate and cache +- Subsequent: Return cached value +- After re-homing: Recalculate on next access + +### Scalability + +Re-homing time is constant regardless of subtree size: + +| Subtree Size | Re-homing Time | +|--------------|----------------| +| 100 nodes | O(1) | +| 10,000 nodes | O(1) | +| 1,000,000 nodes | O(1) | + +This is O(1) because re-homing is a single reference assignment, regardless of how many nodes reference that provider. + +## Common Patterns + +### Pattern 1: Creating a Scope + +```csharp +public class MyNavigation : INavigationHomeProvider, INavigationHomeAccessor +{ + private string _pathPrefix; + + public MyNavigation(string pathPrefix) + { + _pathPrefix = pathPrefix; + HomeProvider = this; // Self-referential initially + } + + public string PathPrefix => HomeProvider == this ? _pathPrefix : HomeProvider.PathPrefix; + public IRootNavigationItem<...> NavigationRoot => /* ... */; + public INavigationHomeProvider HomeProvider { get; set; } +} +``` + +### Pattern 2: Consuming a Scope + +```csharp +public class MyLeaf +{ + private readonly INavigationHomeAccessor _homeAccessor; + + public MyLeaf(INavigationHomeAccessor homeAccessor) + { + _homeAccessor = homeAccessor; + } + + public string Url => + $"{_homeAccessor.HomeProvider.PathPrefix}/{_path}/"; +} +``` + +### Pattern 3: Re-homing + +```csharp +void RehomeSubtree( + INavigationHomeAccessor subtree, + string newPrefix, + IRootNavigationItem<...> newRoot) +{ + subtree.HomeProvider = new NavigationHomeProvider(newPrefix, newRoot); + // ✅ All URLs updated +} +``` + +## Testing + +### Unit Test Example + +```csharp +[Fact] +public void RehomingUpdatesUrlsDynamically() +{ + // Create isolated navigation + var docset = new DocumentationSetNavigation(...); + var leaf = docset.NavigationItems.First() as FileNavigationLeaf; + + // Initial URL + Assert.Equal("/api/rest/", leaf.Url); + + // Re-home the docset + docset.HomeProvider = new NavigationHomeProvider("/guide", siteNav); + + // URL updated ✨ + Assert.Equal("/guide/api/rest/", leaf.Url); +} +``` + +## Summary + +The Home Provider pattern provides: + +✅ **O(1) re-homing** - Single reference assignment updates entire subtree +✅ **Lazy URL evaluation** - URLs calculated on-demand +✅ **Automatic cache invalidation** - Via provider ID comparison +✅ **Memory efficiency** - No stored URL strings +✅ **Scope isolation** - Changes don't leak between scopes + +This enables building isolated documentation repositories and efficiently assembling them into a unified site with custom URL prefixes. The O(1) re-homing is what makes the assembler build practical - without it, combining large documentation sites would require expensive tree traversal for every URL prefix change. diff --git a/docs/development/navigation/images/assembler-build-tree.svg b/docs/development/navigation/images/assembler-build-tree.svg new file mode 100644 index 000000000..f420d4d00 --- /dev/null +++ b/docs/development/navigation/images/assembler-build-tree.svg @@ -0,0 +1,101 @@ + + + + + + + + site:// + path_prefix: "docs" + + + + api/ (docs-content://api) + path_prefix: "api" + + + + api/ (elastic-project://api) [re-homed] + path_prefix: "api/elastic-project" + + + + rest/ + + + + overview.md + /docs/api/elastic-project/rest/overview + + + + overview.md + /docs/api/elastic-project/overview + + + + guides/ (docs-content://guides) + path_prefix: "guides" + + + + guides/ (elastic-project://guides) [re-homed] + path_prefix: "guides/elastic-project" + + + + getting-started.md + /docs/guides/elastic-project/getting-started + + + + elastic-client (elastic-client://) [re-homed] + path_prefix: "clients" + + + + elastic-client-node (elastic-client-node://) [re-homed] + path_prefix: "clients/node" + + + + index.md + /docs/clients/node/ + + + + elastic-client-dotnet (elastic-client-dotnet://) [re-homed] + path_prefix: "clients/dotnet" + + + + index.md + /docs/clients/dotnet/ + + + + elastic-client-java (elastic-client-java://) [re-homed] + path_prefix: "clients/java" + + + + index.md + /docs/clients/java/ + + + + index.md + /docs/clients/ + diff --git a/docs/development/navigation/images/bullet-documentation-set-navigation.svg b/docs/development/navigation/images/bullet-documentation-set-navigation.svg new file mode 100644 index 000000000..5e00bd6d8 --- /dev/null +++ b/docs/development/navigation/images/bullet-documentation-set-navigation.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/development/navigation/images/bullet-file-navigation-leaf.svg b/docs/development/navigation/images/bullet-file-navigation-leaf.svg new file mode 100644 index 000000000..7d2b68413 --- /dev/null +++ b/docs/development/navigation/images/bullet-file-navigation-leaf.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/development/navigation/images/bullet-folder-navigation.svg b/docs/development/navigation/images/bullet-folder-navigation.svg new file mode 100644 index 000000000..45182fc72 --- /dev/null +++ b/docs/development/navigation/images/bullet-folder-navigation.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/development/navigation/images/bullet-site-navigation.svg b/docs/development/navigation/images/bullet-site-navigation.svg new file mode 100644 index 000000000..697d99550 --- /dev/null +++ b/docs/development/navigation/images/bullet-site-navigation.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/development/navigation/images/bullet-table-of-contents-navigation.svg b/docs/development/navigation/images/bullet-table-of-contents-navigation.svg new file mode 100644 index 000000000..a02f590d0 --- /dev/null +++ b/docs/development/navigation/images/bullet-table-of-contents-navigation.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/development/navigation/images/isolated-build-tree.svg b/docs/development/navigation/images/isolated-build-tree.svg new file mode 100644 index 000000000..55d0a0b50 --- /dev/null +++ b/docs/development/navigation/images/isolated-build-tree.svg @@ -0,0 +1,49 @@ + + + + + + + + elastic-project:// (docset.yml) + path_prefix: "documentation" + + + + api/ (api/toc.yml) + + + + rest/ + + + + overview.md + /documentation/api/rest/overview + + + + overview.md + /documentation/api/overview + + + + guides/ (guides/toc.yml) + + + + getting-started.md + /documentation/guides/getting-started + + + + index.md + /documentation/ + diff --git a/docs/development/navigation/navigation.md b/docs/development/navigation/navigation.md new file mode 100644 index 000000000..b2b458654 --- /dev/null +++ b/docs/development/navigation/navigation.md @@ -0,0 +1,75 @@ +# Navigation + +This document provides an overview of how `Elastic.Documentation.Navigation` works. + +## Documentation Structure + +**Start here:** +- **[Visual Walkthrough](visual-walkthrough.md)** - Visual tour with diagrams (best for first-time readers) +- **[First Principles](first-principles.md)** - Core design principles (functional and technical) + +**Learn the architecture:** +- **[Functional Principles](functional-principles.md)** - What the system does and why +- **[Two-Phase Loading](two-phase-loading.md)** - Configuration resolution vs navigation construction +- **[Node Types](node-types.md)** - Reference for each navigation node type + +**Understand key mechanisms:** +- **[Home Provider Architecture](home-provider-architecture.md)** - How O(1) re-homing works +- **[Assembler Process](assembler-process.md)** - How multiple repositories combine into one site + +**Implementation details:** +- **[Technical Principles](technical-principles.md)** - How the system is implemented + +## Quick Start + +### Core Concepts + +The navigation system builds hierarchical trees for documentation sites with these key features: + +1. **Two Build Modes:** + - **Isolated** - Single repository (e.g., `docs-builder isolated build`) + - **Assembler** - Multi-repository site (e.g., `docs-builder assemble`) + +2. **Two-Phase Loading:** + - **Phase 1**: Parse YAML, resolve paths → Configuration + - **Phase 2**: Build tree, calculate URLs → Navigation + +3. **Re-homing:** + - Build navigation in isolation with URLs like `/api/rest/` + - Re-home during assembly to URLs like `/elasticsearch/api/rest/` + - **O(1) operation** - no tree traversal needed! + +### How Re-homing Works + +The key innovation is the [Home Provider pattern](home-provider-architecture.md): + +```csharp +// Isolated build +DocumentationSetNavigation +{ + HomeProvider: self, + PathPrefix: "" +} +// Child URL: /api/rest/ + +// Re-home for assembler build (ONE LINE!) +docset.HomeProvider = new NavigationHomeProvider("/guide", siteNav); + +// Child URL: /guide/api/rest/ ✨ All URLs updated! +``` + +This is possible because URLs are **calculated dynamically** from the HomeProvider, not stored. Changing the provider instantly updates all descendant URLs without any tree traversal. + +See [Home Provider Architecture](home-provider-architecture.md) for the complete explanation. + +## Visual Examples + +For a visual tour of navigation structures with diagrams showing both isolated and assembler builds, see the **[Visual Walkthrough](visual-walkthrough.md)**. + +The walkthrough covers: +- What nodes look like in isolated vs assembler builds +- How the same content appears with different URLs +- How to split and reorganize documentation across the site +- Common patterns for organizing multi-repository sites +- Examples with the actual tree diagrams from this repository + diff --git a/docs/development/navigation/node-types.md b/docs/development/navigation/node-types.md new file mode 100644 index 000000000..c809e2811 --- /dev/null +++ b/docs/development/navigation/node-types.md @@ -0,0 +1,641 @@ +# Navigation Node Types + +This document provides a detailed reference for each navigation node type in `Elastic.Documentation.Navigation`. + +> **Context:** For the acyclic graph structure that these nodes form, see [Functional Principles #8](functional-principles.md#8.-acyclic-graph-structure). + +## Type Hierarchy + +``` +INavigationItem +├── ILeafNavigationItem +│ ├── FileNavigationLeaf +│ └── CrossLinkNavigationLeaf +│ +└── INodeNavigationItem + ├── IRootNavigationItem + │ ├── DocumentationSetNavigation + │ ├── TableOfContentsNavigation + │ └── SiteNavigation + │ + ├── FolderNavigation + └── VirtualFileNavigation +``` + +## Common Properties + +All navigation items implement `INavigationItem`: + +```csharp +public interface INavigationItem +{ + /// The URL for this navigation item + string Url { get; } + + /// Title displayed in navigation + string NavigationTitle { get; } + + /// Root of the navigation tree + IRootNavigationItem NavigationRoot { get; } + + /// Parent in the tree, null for roots + INodeNavigationItem? Parent { get; set; } + + /// Whether this item is hidden from navigation + bool Hidden { get; } + + /// Breadth-first index in the tree + int NavigationIndex { get; set; } +} +``` + +--- + +## Leaf Nodes + +Leaf nodes have no children. They represent individual documentation files or external links. + +### FileNavigationLeaf + +Represents an individual markdown file in the documentation. + +**Location:** `src/Elastic.Documentation.Navigation/Isolated/FileNavigationLeaf.cs` + +**YAML Declaration:** +```yaml +toc: + - file: getting-started.md + - file: api/overview.md # Can deep-link + - hidden: 404.md # Hidden from navigation +``` + +**Key Features:** +- URL calculated dynamically from home provider + relative path +- Smart caching (see [Home Provider Architecture](home-provider-architecture.md)) +- Handles index files specially: `folder/index.md` → `/folder/` +- Can be hidden from navigation while remaining accessible + +**URL Calculation:** +```csharp +public string Url +{ + get + { + var rootUrl = _homeAccessor.HomeProvider.PathPrefix.TrimEnd('/'); + var relativePath = DetermineRelativePath(); + + // Remove .md extension + var path = relativePath.EndsWith(".md") ? relativePath[..^3] : relativePath; + + // Handle index files + if (path.EndsWith("/index")) + path = path[..^6]; + else if (path.Equals("index")) + return string.IsNullOrEmpty(rootUrl) ? "/" : $"{rootUrl}/"; + + return $"{rootUrl}/{path.TrimEnd('/')}/"; + } +} +``` + +**Example:** +``` +File: docs/api/rest.md +PathPrefix: "/guide" +URL: /guide/api/rest/ + +File: docs/index.md +PathPrefix: "/guide" +URL: /guide/ +``` + +**Constructor:** +```csharp +public FileNavigationLeaf( + TModel model, // The documentation file model + IFileInfo fileInfo, // File system info + FileNavigationArgs args) // Construction arguments +``` + +**Arguments:** +- `RelativePathToDocumentationSet` - Path from docset root (for URL calculation) +- `RelativePathToTableOfContents` - Path from TOC root (for assembler builds) +- `Hidden` - Whether hidden from navigation +- `NavigationIndex` - Initial index (will be recalculated) +- `Parent` - Parent node +- `HomeAccessor` - For accessing path prefix and navigation root + +--- + +### CrossLinkNavigationLeaf + +Represents a link to external documentation or different documentation set. + +**Location:** `src/Elastic.Documentation.Navigation/Isolated/CrossLinkNavigationLeaf.cs` + +**YAML Declaration:** +```yaml +toc: + - title: "External Guide" + crosslink: https://example.com/guide + - title: "Other Docset" + crosslink: docs-content://guide.md +``` + +**Key Features:** +- URL is the crosslink itself (not calculated) +- Can link to external sites or use crosslink scheme +- Title is required (no auto-title from file) + +**Constructor:** +```csharp +public CrossLinkNavigationLeaf( + CrossLinkModel model, // Contains Uri and title + string url, // The crosslink URL + bool hidden, // Hidden from navigation? + INodeNavigationItem<...>? parent, // Parent node + INavigationHomeAccessor homeAccessor) // For navigation root +``` + +**Example:** +```csharp +new CrossLinkNavigationLeaf( + new CrossLinkModel(new Uri("https://elastic.co"), "Elastic Docs"), + "https://elastic.co", + hidden: false, + parent: this, + homeAccessor: this +) +// URL: https://elastic.co +// NavigationTitle: "Elastic Docs" +``` + +--- + +## Node Types (With Children) + +Node types can have child navigation items. They represent structural elements of the documentation. + +### FolderNavigation + +Represents a directory in the file system with markdown files. + +**Location:** `src/Elastic.Documentation.Navigation/Isolated/FolderNavigation.cs` + +**YAML Declaration:** +```yaml +toc: + - folder: getting-started + # Auto-discovers markdown files in the folder + + - folder: api + children: + - file: index.md + - file: rest.md + # Explicit children, no auto-discovery +``` + +**Key Features:** +- URL is the same as its `Index` property +- Index is either `index.md` or first file +- Can auto-discover markdown files if no children specified +- Children paths are scoped to the folder + +**Properties:** +```csharp +public class FolderNavigation +{ + public string FolderPath { get; } // Relative path to folder + public ILeafNavigationItem Index { get; } // Folder's index file + public IReadOnlyCollection NavigationItems { get; } // Children +} +``` + +**URL:** +```csharp +public string Url => Index.Url; // Same as index file +``` + +**Example:** +``` +Folder: docs/getting-started/ +Files: + - index.md + - install.md + - configure.md + +Navigation: +FolderNavigation + Index: getting-started/index.md → /getting-started/ + NavigationItems: + - install.md → /getting-started/install/ + - configure.md → /getting-started/configure/ +``` + +--- + +### VirtualFileNavigation + +Represents a file with children defined in YAML (not file system structure). + +**Location:** `src/Elastic.Documentation.Navigation/Isolated/VirtualFileNavigation.cs` + +**YAML Declaration:** +```yaml +toc: + - file: getting-started.md + children: + - file: install.md + - file: configure.md + # Children can be anywhere in the file system +``` + +**Key Features:** +- Allows grouping files without matching file system structure +- Index is the file itself +- Children don't have to be in the same directory +- URL is the same as its `Index` property + +**Properties:** +```csharp +public class VirtualFileNavigation +{ + public ILeafNavigationItem Index { get; } // The file itself + public IReadOnlyCollection NavigationItems { get; } // Virtual children +} +``` + +**Example:** +``` +File: docs/getting-started.md +Children (defined in YAML): + - docs/install.md + - docs/setup.md + +Navigation: +VirtualFileNavigation + Index: getting-started.md → /getting-started/ + NavigationItems: + - install.md → /install/ + - setup.md → /setup/ +``` + +**Use Cases:** +- Grouping related files that aren't in the same directory +- Creating navigation structure independent of file structure +- Collecting files under a parent concept + +**Best Practice:** Use sparingly. Prefer `FolderNavigation` when file structure can match navigation structure. + +--- + +## Root Node Types + +Root nodes can be re-homed in assembler builds. They implement `IRootNavigationItem`. + +### DocumentationSetNavigation + +Represents the root navigation for a documentation set (`docset.yml`). + +**Location:** `src/Elastic.Documentation.Navigation/Isolated/DocumentationSetNavigation.cs` + +**Source:** `docset.yml` file + +**Key Features:** +- Root of navigation tree in isolated builds +- Can be re-homed in assembler builds +- Creates home provider scope +- Implements both `INavigationHomeProvider` and `INavigationHomeAccessor` +- Has unique identifier: `{repository}://` + +**Properties:** +```csharp +public class DocumentationSetNavigation + : IRootNavigationItem + , INavigationHomeProvider + , INavigationHomeAccessor +{ + public Uri Identifier { get; } // e.g., elastic-docs:// + public string PathPrefix { get; } // URL prefix for this docset + public GitCheckoutInformation Git { get; } // Repository info + public INavigationHomeProvider HomeProvider { get; set; } // For re-homing! + + public ILeafNavigationItem Index { get; } // Docset index + public IReadOnlyCollection NavigationItems { get; } // Top-level items + public bool IsUsingNavigationDropdown { get; } // From features.primary-nav +} +``` + +**Isolated Build:** +```csharp +// In isolated builds, it's its own home provider +DocumentationSetNavigation +{ + NavigationRoot = this, + HomeProvider = this, + PathPrefix = "", + Identifier = new Uri("elastic-docs://") +} +// Child URL: /api/rest/ +``` + +**Assembler Build (Re-homed):** +```csharp +// In assembler builds, re-homed to site navigation +DocumentationSetNavigation +{ + NavigationRoot = SiteNavigation, // Changed! + HomeProvider = new NavigationHomeProvider( // Changed! + pathPrefix: "/guide", + navigationRoot: SiteNavigation + ), + PathPrefix = "/guide", // From new provider + Identifier = new Uri("elastic-docs://") // Unchanged +} +// Child URL: /guide/api/rest/ +``` + +**Re-homing:** +```csharp +// This is all it takes! +docsetNav.HomeProvider = new NavigationHomeProvider("/guide", siteNav); +``` + +--- + +### TableOfContentsNavigation + +Represents a nested `toc.yml` file within a documentation set. + +**Location:** `src/Elastic.Documentation.Navigation/Isolated/TableOfContentsNavigation.cs` + +**Source:** `toc.yml` file + +**YAML Declaration (in docset.yml or parent toc.yml):** +```yaml +toc: + - toc: api + - toc: guides +``` + +**Key Features:** +- **Creates a scope only in assembler builds** (for independent re-homing) +- In isolated builds, inherits HomeProvider from DocumentationSetNavigation +- Can be re-homed independently in assembler builds +- Implements both `INavigationHomeProvider` and `INavigationHomeAccessor` +- Has unique identifier: `{repository}://{path}` +- Cannot have children defined in YAML (children come from toc.yml file) + +**Properties:** +```csharp +public class TableOfContentsNavigation + : IRootNavigationItem + , INavigationHomeProvider + , INavigationHomeAccessor +{ + public Uri Identifier { get; } // e.g., elastic-docs://api + public string ParentPath { get; } // Path to toc folder + public string PathPrefix { get; } // URL prefix + public IDirectoryInfo TableOfContentsDirectory { get; } // Physical directory + public INavigationHomeProvider HomeProvider { get; set; } // For re-homing! + + public ILeafNavigationItem Index { get; } // TOC index + public IReadOnlyCollection NavigationItems { get; } // TOC items +} +``` + +**Example:** +``` +Docset: elastic-docs +TOC: api/toc.yml + +In isolated build: +TableOfContentsNavigation +{ + Identifier = new Uri("elastic-docs://api"), + ParentPath = "api", + PathPrefix = "", + NavigationRoot = DocumentationSetNavigation, + HomeProvider = DocumentationSetNavigation.HomeProvider // ← Inherited, no new scope +} +// Child URL: /api/rest/ + +In assembler build (creates its own scope for re-homing): +TableOfContentsNavigation +{ + Identifier = new Uri("elastic-docs://api"), + ParentPath = "api", + PathPrefix = "/reference", // Different from docset! + NavigationRoot = SiteNavigation, + HomeProvider = new NavigationHomeProvider( // ← New scope created! + pathPrefix: "/reference", + navigationRoot: SiteNavigation + ) +} +// Child URL: /reference/rest/ +``` + +**Re-homing:** +```csharp +// TOCs can be re-homed independently from their parent docset! +tocNav.HomeProvider = new NavigationHomeProvider("/reference", siteNav); +``` + +**Use Case:** Allows assembler builds to split a docset across multiple site sections. + +--- + +### SiteNavigation + +Represents the root navigation for an assembled documentation site. + +**Location:** `src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs` + +**Source:** `config/navigation.yml` + +**Key Features:** +- Only exists in assembler builds +- Ultimate root of the navigation tree +- Re-homes child DocumentationSetNavigation and TableOfContentsNavigation nodes +- Manages `path_prefix` mappings from navigation.yml +- Tracks phantom nodes (declared but not included) +- Has unique identifier: `site://` + +**Properties:** +```csharp +public class SiteNavigation + : IRootNavigationItem +{ + public Uri Identifier { get; } = new Uri("site://"); + public string Url { get; } // Site prefix or "/" + + // All docset/TOC nodes indexed by identifier + public IReadOnlyDictionary> Nodes { get; } + + // Top-level navigation items + public ILeafNavigationItem Index { get; } + public IReadOnlyCollection NavigationItems { get; } + + // Phantom tracking + public IReadOnlyCollection Phantoms { get; } + public HashSet DeclaredPhantoms { get; } + public ImmutableHashSet DeclaredTableOfContents { get; } +} +``` + +**Example:** +```yaml +# config/navigation.yml +toc: + - toc: elastic-docs:// + path_prefix: guide + + - toc: elastic-docs://api + path_prefix: reference + +phantoms: + - source: plugins:// # Not included in navigation +``` + +```csharp +SiteNavigation +{ + Identifier = new Uri("site://"), + NavigationRoot = this, + Nodes = { + [new Uri("elastic-docs://")] = DocumentationSetNavigation { ... }, + [new Uri("elastic-docs://api")] = TableOfContentsNavigation { ... }, + }, + NavigationItems = [ + DocumentationSetNavigation (re-homed to /guide), + TableOfContentsNavigation (re-homed to /reference) + ] +} +``` + +**Re-homing Logic:** +```csharp +// From SiteNavigation.cs:211 +private INavigationItem? CreateSiteTableOfContentsNavigation(...) +{ + var pathPrefix = $"{_sitePrefix}/{tocRef.PathPrefix}".Trim('/'); + + // Look up the node + if (!_nodes.TryGetValue(tocRef.Source, out var node)) + return null; + + // Re-home it! + homeAccessor.HomeProvider = new NavigationHomeProvider(pathPrefix, root); + + // All URLs in subtree now use pathPrefix! + return node; +} +``` + +--- + +## Type Comparison Table + +| Type | Has Children | Is Root | Can Be Re-homed | Creates Scope | URL Source | +|------|-------------|---------|-----------------|---------------|------------| +| **FileNavigationLeaf** | ❌ | ❌ | ❌ | ❌ | Calculated from path + prefix | +| **CrossLinkNavigationLeaf** | ❌ | ❌ | ❌ | ❌ | Crosslink URI itself | +| **FolderNavigation** | ✅ | ❌ | ❌ | ❌ | Same as Index | +| **VirtualFileNavigation** | ✅ | ❌ | ❌ | ❌ | Same as Index | +| **DocumentationSetNavigation** | ✅ | ✅ | ✅ | ✅ (always) | Same as Index | +| **TableOfContentsNavigation** | ✅ | ✅ | ✅ | ✅ (assembler only) | Same as Index | +| **SiteNavigation** | ✅ | ✅ | ❌ | ✅ (always) | Site prefix or "/" | + +**Note:** TableOfContentsNavigation only creates its own scope in assembler builds to enable independent re-homing. In isolated builds, it inherits the HomeProvider from its parent DocumentationSetNavigation. + +## Factory Methods + +Navigation items are created through factory methods in `DocumentationNavigationFactory`: + +```csharp +public static class DocumentationNavigationFactory +{ + // Create a file leaf + public static ILeafNavigationItem CreateFileNavigationLeaf( + TModel model, + IFileInfo fileInfo, + FileNavigationArgs args) + where TModel : IDocumentationFile + => new FileNavigationLeaf(model, fileInfo, args) + { NavigationIndex = args.NavigationIndex }; + + // Create a virtual file node + public static VirtualFileNavigation CreateVirtualFileNavigation( + TModel model, + IFileInfo fileInfo, + VirtualFileNavigationArgs args) + where TModel : IDocumentationFile + => new(model, fileInfo, args) + { NavigationIndex = args.NavigationIndex }; +} +``` + +**Why Factory Methods?** +- Encapsulate creation logic +- Ensure consistent initialization (NavigationIndex) +- Type-safe generic construction +- Centralize instantiation + +## Model Types + +All navigation items work with models that implement `IDocumentationFile`: + +```csharp +public interface IDocumentationFile : INavigationModel +{ + string NavigationTitle { get; } +} +``` + +**Built-in Models:** + +### CrossLinkModel +```csharp +public record CrossLinkModel(Uri CrossLinkUri, string NavigationTitle) + : IDocumentationFile; +``` + +### SiteNavigationNoIndexFile +```csharp +public record SiteNavigationNoIndexFile(string NavigationTitle) + : IDocumentationFile; +``` + +**Custom Models:** + +You can create custom models for specialized documentation types: + +```csharp +public record ApiDocumentationFile( + string NavigationTitle, + string ApiVersion, + ApiType Type +) : IDocumentationFile; + +// Use with generic navigation +var navigation = new DocumentationSetNavigation( + docset, + context, + new ApiDocumentationFileFactory() +); +``` + +## Summary + +The navigation system provides: + +- **7 node types** - 2 leaves, 3 nodes, 3 roots +- **Generic design** - Works with any `IDocumentationFile` model +- **Flexible structure** - Files, folders, TOCs, virtual files +- **Re-homing** - Roots can change URL prefix in O(1) +- **Scope isolation** - Each root creates its own URL scope +- **Type safety** - Factory methods ensure correct construction + +For implementation details, see the source code in: +- `src/Elastic.Documentation.Navigation/Isolated/` - Individual node types +- `src/Elastic.Documentation.Navigation/Assembler/` - Site assembly diff --git a/docs/development/navigation/technical-principles.md b/docs/development/navigation/technical-principles.md new file mode 100644 index 000000000..5bd44a568 --- /dev/null +++ b/docs/development/navigation/technical-principles.md @@ -0,0 +1,123 @@ +# Technical Principles + +These principles define how the navigation system is implemented. + +> **Prerequisites:** Read [Functional Principles](functional-principles.md) first to understand what the system does and why. + +## 1. Generic Type System for Covariance + +Navigation classes are generic over `TModel`: +```csharp +public class DocumentationSetNavigation + where TModel : class, IDocumentationFile +``` + +**Why Generic?** + +**Covariance Enables Static Typing:** +```csharp +// Without covariance: always get base interface, requires runtime casts +INodeNavigationItem node = GetNode(); +if (node.Model is MarkdownFile markdown) // Runtime check required +{ + var content = markdown.Content; +} + +// With covariance: query for specific type statically +INodeNavigationItem node = QueryForMarkdownNodes(); +var content = node.Model.Content; // ✓ No cast needed! Static type safety +``` + +**Benefits:** +- **Type Safety**: Query methods can return specific types like `INodeNavigationItem` +- **No Runtime Casts**: Access `.Model.Content` directly without casting +- **Compile-Time Errors**: Type mismatches caught during compilation, not runtime +- **Better IntelliSense**: IDEs show correct members for specific model types +- **Flexibility**: Same navigation code works with different file models (MarkdownFile, ApiDocFile, etc.) + +**Example:** +```csharp +// Query for nodes with specific model type +var markdownNodes = navigation.NavigationItems + .OfType>(); + +foreach (var node in markdownNodes) +{ + // No cast needed! Static typing + Console.WriteLine(node.Model.FrontMatter); + Console.WriteLine(node.Model.Content); +} +``` + +## 2. Provider Pattern for URL Context + +`INavigationHomeProvider` / `INavigationHomeAccessor`: +- **Providers** define context (PathPrefix, NavigationRoot) +- **Accessors** reference providers +- Decouples URL calculation from tree structure +- Enables context switching (re-homing) + +**Why This Enables Re-homing:** +```csharp +// Isolated build +node.HomeProvider = new NavigationHomeProvider("", docsetRoot); +// URLs: /api/rest/ + +// Assembler build - O(1) operation! +node.HomeProvider = new NavigationHomeProvider("/guide", siteRoot); +// URLs: /guide/api/rest/ +``` + +Single reference change updates all descendant URLs. + +> See [Home Provider Architecture](home-provider-architecture.md) for complete explanation. + +## 3. Lazy URL Calculation with Caching + +`FileNavigationLeaf` implements smart URL caching: +```csharp +private string? _homeProviderCache; +private string? _urlCache; + +public string Url +{ + get + { + if (_homeProviderCache == HomeProvider.Id && _urlCache != null) + return _urlCache; + + _urlCache = CalculateUrl(); + _homeProviderCache = HomeProvider.Id; + return _urlCache; + } +} +``` + +**Strategy:** +- Cache URL along with HomeProvider ID +- Invalidate cache when HomeProvider changes +- Recalculate only when needed +- O(1) for repeated access, O(depth) for calculation + +**Why HomeProvider.Id?** +- Each HomeProvider has a unique ID +- Comparing IDs is cheaper than deep equality checks +- ID changes when provider is replaced during re-homing +- Automatic cache invalidation without explicit cache clearing + +--- + +## Performance Characteristics + +- **Tree Construction**: O(n) where n = number of files +- **URL Calculation**: O(depth) for first access, O(1) with caching +- **Re-homing**: O(1) - just replace HomeProvider reference +- **Tree Traversal**: O(n) for full tree, but rarely needed +- **Memory**: O(n) for nodes, URLs computed on-demand + +**Why Re-homing is O(1):** +1. Replace single HomeProvider reference +2. No tree traversal required +3. URLs lazy-calculated on next access +4. Cache invalidation via ID comparison +5. All descendants automatically use new provider diff --git a/docs/development/navigation/two-phase-loading.md b/docs/development/navigation/two-phase-loading.md new file mode 100644 index 000000000..15c649614 --- /dev/null +++ b/docs/development/navigation/two-phase-loading.md @@ -0,0 +1,316 @@ +# Two-Phase Loading + +Navigation construction splits into two distinct phases: configuration resolution and navigation building. + +> **Overview:** For a high-level understanding, see [Functional Principles #1](functional-principles.md#1.-two-phase-loading). This document provides detailed implementation information. + +## Why Two Phases? + +Building navigation requires two fundamentally different operations: +1. **Loading configuration** - Parse YAML, check files exist, resolve paths +2. **Building structure** - Create tree, set relationships, calculate URLs + +These operations have different concerns: + +| Aspect | Configuration | Navigation | +|--------|--------------|------------| +| **Input** | File system + YAML | Resolved paths | +| **Validation** | Files exist, YAML valid | Tree structure valid | +| **Errors** | Missing files, bad YAML | Empty TOCs, broken links | +| **Changes when** | YAML format changes | Tree logic changes | +| **Testing needs** | Mock file system | Mock config objects | + +Mixing them creates coupling. Configuration parsing shouldn't know about tree structure. Tree building shouldn't touch the file system. + +**Concrete benefits:** + +**Error messages are clearer:** +``` +Phase 1: File 'api/missing.md' not found at /docs/api/missing.md +Phase 2: Folder 'setup' has children defined but none could be created +``` +You immediately know which layer failed. + +**Testing is simpler:** +```csharp +// Phase 1 test: Does path resolution work? +[Fact] void ResolvesNestedPaths() { /* mock file system */ } + +// Phase 2 test: Does tree structure work? +[Fact] void CreatesNavigationTree() { /* mock config, no files */ } +``` +Each phase tests one thing. + +**Configuration reuses:** +```csharp +// Parse once +var config = DocumentationSetFile.LoadAndResolve(yaml, fileSystem); + +// Build multiple ways +var isolated = new DocumentationSetNavigation(config, isolatedContext, factory); +var assembled = new SiteNavigation(siteConfig, context, [isolated], prefix); +``` +Same configuration, different navigation structures. + +**Separation of concerns:** +- Change YAML format → only Phase 1 changes +- Change URL calculation → only Phase 2 changes +- Swap YAML for JSON → only Phase 1 changes +- Add new node type → only Phase 2 changes + +## Phase 1: Configuration Resolution + +**Package:** `Elastic.Documentation.Configuration` + +**Goal:** Parse YAML → Resolve paths → Validate existence + +``` +Raw YAML + File System → Fully resolved configuration +``` + +**What it does:** +1. Parse YAML files (`docset.yml`, `toc.yml`, `navigation.yml`) +2. Resolve relative paths to absolute paths from docset root +3. Validate files exist on disk +4. Load nested `toc.yml` files recursively +5. Emit configuration errors + +**Example:** +```csharp +// In: Raw YAML +toc: + - toc: api + # api/toc.yml contains: + toc: + - file: rest.md # Relative to api/toc.yml + +// Out: Fully resolved +FileRef { + PathRelativeToDocumentationSet = "api/rest.md" // ✓ From docset root +} +``` + +**Key point:** All paths become relative to docset root. No more file I/O needed. + +## Phase 2: Navigation Construction + +**Package:** `Elastic.Documentation.Navigation` + +**Goal:** Build tree → Calculate URLs → Set relationships + +``` +Resolved Configuration → Navigation tree with URLs +``` + +**What it does:** +1. Create node objects from configuration +2. Set parent-child relationships +3. Set up home providers (for URL calculation) +4. Calculate navigation indexes +5. Emit navigation errors + +**Example:** +```csharp +// In: Resolved configuration +FileRef { PathRelativeToDocumentationSet = "api/rest.md" } + +// Out: Navigation with URL +FileNavigationLeaf { + Url = "/api/rest/", + Parent = TableOfContentsNavigation, + NavigationRoot = DocumentationSetNavigation +} +``` + +**Key point:** URLs calculated dynamically from HomeProvider. No stored paths. + +## The Flow + +``` +┌─────────────────────────────────────┐ +│ Phase 1: Configuration │ +├─────────────────────────────────────┤ +│ YAML files + File system │ +│ ↓ │ +│ Parse & validate │ +│ ↓ │ +│ Resolve all paths │ +│ ↓ │ +│ DocumentationSetFile │ +│ (all paths relative to docset root) │ +└─────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────┐ +│ Phase 2: Navigation │ +├─────────────────────────────────────┤ +│ Resolved configuration │ +│ ↓ │ +│ Build tree │ +│ ↓ │ +│ Set relationships │ +│ ↓ │ +│ Set up URL providers │ +│ ↓ │ +│ DocumentationSetNavigation │ +│ (complete tree with URLs) │ +└─────────────────────────────────────┘ +``` + +## Path Resolution Example + +**Before Phase 1:** +```yaml +# docset.yml +toc: + - toc: api + +# api/toc.yml +toc: + - file: rest.md # ← Relative to api/ + - file: graphql.md # ← Relative to api/ +``` + +**After Phase 1:** +```csharp +IsolatedTableOfContentsRef { + PathRelativeToDocumentationSet = "api", + Children = [ + FileRef { PathRelativeToDocumentationSet = "api/rest.md" }, // ✓ + FileRef { PathRelativeToDocumentationSet = "api/graphql.md" } // ✓ + ] +} +``` + +All paths now relative to docset root. Phase 2 can build without touching filesystem. + +## Error Attribution + +Clear errors because phases are separate: + +**Phase 1 errors (configuration):** +``` +Error: File 'api/missing.md' not found at /docs/api/missing.md +Error: TableOfContents 'api' cannot have children in docset.yml +``` +→ Fix your YAML or add the file. + +**Phase 2 errors (navigation):** +``` +Error: Documentation set has no table of contents defined +Error: Folder 'setup' has children defined but none could be created +``` +→ Fix your navigation structure. + +## Testing Benefits + +**Phase 1 tests:** +```csharp +[Fact] +public void LoadAndResolve_ResolvesNestedPaths() +{ + var yaml = "toc:\n - toc: api"; + var fs = new MockFileSystem(); + fs.AddFile("/docs/api/toc.yml", "toc:\n - file: rest.md"); + + var docset = DocumentationSetFile.LoadAndResolve( + collector, yaml, fs.NewDirInfo("/docs") + ); + + var fileRef = docset.TableOfContents[0].Children[0] as FileRef; + Assert.Equal("api/rest.md", fileRef.PathRelativeToDocumentationSet); +} +``` +Tests YAML parsing and path resolution. + +**Phase 2 tests:** +```csharp +[Fact] +public void Constructor_CreatesNavigationTree() +{ + // Pre-resolved configuration (no file I/O!) + var docset = new DocumentationSetFile { + TableOfContents = [ + new FileRef { PathRelativeToDocumentationSet = "index.md" } + ] + }; + + var nav = new DocumentationSetNavigation( + docset, context, factory + ); + + Assert.Equal("/", nav.Index.Url); +} +``` +Tests tree construction without file system. + +## Reusability + +Same configuration works for both build modes: + +```csharp +// Phase 1: Build configuration once +var docset = DocumentationSetFile.LoadAndResolve( + collector, yaml, fileSystem.NewDirInfo("/docs") +); + +// Phase 2a: Isolated build +var isolatedNav = new DocumentationSetNavigation( + docset, // ← Same config + isolatedContext, + factory +); +// URLs: /api/rest/ + +// Phase 2b: Assembler build +var siteNav = new SiteNavigation( + siteConfig, + assemblerContext, + [isolatedNav], // ← Reuse isolated navigation + sitePrefix: null +); +// Re-home: /api/rest/ → /elasticsearch/api/rest/ +``` + +## Assembler Extension + +Assembler adds two more phases: + +``` +Phase 1a: Load individual docset configs + ↓ +Phase 2a: Build isolated navigations + ↓ +Phase 1b: Load site navigation config + ↓ +Phase 2b: Assemble + re-home +``` + +Each docset goes through Phases 1 & 2 independently, then site navigation assembles them. + +## Key Invariants + +**After Phase 1:** +- ✅ All paths relative to docset root +- ✅ All files validated to exist +- ✅ All nested TOCs loaded +- ✅ Configuration structure validated + +**After Phase 2:** +- ✅ Complete navigation tree +- ✅ All relationships set (parent/child/root) +- ✅ All home providers configured +- ✅ All URLs calculable + +## Summary + +| Aspect | Phase 1 | Phase 2 | +|--------|---------|---------| +| **Package** | `Configuration` | `Navigation` | +| **Input** | YAML + File system | Resolved config | +| **Output** | Resolved config | Navigation tree | +| **Errors** | Config/file issues | Structure issues | +| **File I/O** | Yes | No | +| **Testing** | Mock file system | Mock config | +| **Reusable** | Yes (both builds) | Build-specific | + +**The key insight:** Configuration is about files and YAML. Navigation is about tree structure and URLs. Keep them separate. diff --git a/docs/development/navigation/visual-walkthrough.md b/docs/development/navigation/visual-walkthrough.md new file mode 100644 index 000000000..aaf9f1eba --- /dev/null +++ b/docs/development/navigation/visual-walkthrough.md @@ -0,0 +1,203 @@ +# Visual Walkthrough + +This visual guide shows how documentation navigation works in practice. We'll use diagrams to show how the same content appears differently in isolated vs assembler builds. + +> **Best for:** First-time readers who want to understand navigation visually before diving into details. + +## Navigation Node Icons + +These icons represent different parts of the navigation tree: + +- ![DocumentationSetNavigation](images/bullet-documentation-set-navigation.svg) **DocumentationSetNavigation** - Root of a repository +- ![TableOfContentsNavigation](images/bullet-table-of-contents-navigation.svg) **TableOfContentsNavigation** - A `toc.yml` section +- ![FolderNavigation](images/bullet-folder-navigation.svg) **FolderNavigation** - A directory +- ![FileNavigationLeaf](images/bullet-file-navigation-leaf.svg) **FileNavigationLeaf** - A markdown file +- ![SiteNavigation](images/bullet-site-navigation.svg) **SiteNavigation** - Root of assembled site + +## Isolated Builds + +Building a single repository (e.g., `docs-builder isolated build`): + +**Example docset.yml:** +```yaml +project: elastic-project +toc: + - file: index.md + - toc: api # api/toc.yml + - toc: guides # guides/toc.yml +``` + +### Visual: Isolated Build Tree + +![Isolated Build](images/isolated-build-tree.svg) + +**What the diagram shows:** + +- ![DocumentationSetNavigation](images/bullet-documentation-set-navigation.svg) `elastic-project://` - Repository root +- ![TableOfContentsNavigation](images/bullet-table-of-contents-navigation.svg) `api/` and `guides/` - Sections from `toc.yml` files +- ![FileNavigationLeaf](images/bullet-file-navigation-leaf.svg) Individual files under each section +- URLs: `/api/overview/`, `/guides/getting-started/` + +**Key points:** +- One repository = one navigation tree +- URLs default to `/` (configurable with `--url-path-prefix`) +- Fast for testing and iteration + +--- + +## Assembler Builds + +Combining multiple repositories into one site (e.g., `docs-builder assemble`): + +**Example: Split One Repository Across Site** + +Take the same `elastic-project` from above and split it: + +```yaml +# navigation.yml +toc: + - toc: elastic-project://api + path_prefix: elasticsearch/api + + - toc: elastic-project://guides + path_prefix: elasticsearch/guides +``` + +**Result:** +- `/api/` → `/elasticsearch/api/` +- `/guides/` → `/elasticsearch/guides/` + +Same content, different URLs! + +### Visual: Assembler Build Tree + +![Assembler Build](images/assembler-build-tree.svg) + +**What the diagram shows:** + +- ![SiteNavigation](images/bullet-site-navigation.svg) `site://` - Root of entire assembled site +- ![TableOfContentsNavigation](images/bullet-table-of-contents-navigation.svg) `elastic-project://api` - Re-homed from `/api/` to `/elasticsearch/api/` +- ![TableOfContentsNavigation](images/bullet-table-of-contents-navigation.svg) `elastic-project://guides` - Re-homed from `/guides/` to `/elasticsearch/guides/` +- ![FileNavigationLeaf](images/bullet-file-navigation-leaf.svg) Files automatically use new prefixes + +**Key points:** +- Multiple repositories → one site +- Custom URL prefixes via `path_prefix` +- Re-homing changes URLs without rebuilding + +> **How re-homing works:** See [Assembler Process](assembler-process.md) for the four-phase assembly process and [Home Provider Architecture](home-provider-architecture.md) for how URLs update instantly (O(1)). + +--- + +## Same File, Different URLs + +**File:** `elastic-project/api/overview.md` + +**Isolated Build:** +``` +URL: /api/overview/ +``` + +**Assembler Build:** +``` +URL: /elasticsearch/api/overview/ +``` + +Same file, same tree structure, different URL prefix. The assembler re-homes the navigation subtree without rebuilding it. + +--- + +## Common Assembly Patterns + +**Keep repository together:** +```yaml +- toc: elastic-project:// + path_prefix: elasticsearch +``` +→ Everything under `/elasticsearch/` + +**Split repository apart:** +```yaml +- toc: elastic-project://api + path_prefix: reference/api +- toc: elastic-project://guides + path_prefix: learn/guides +``` +→ API under `/reference/api/`, guides under `/learn/guides/` + +**Combine multiple repositories:** +```yaml +- toc: clients + children: + - toc: java-client:// + path_prefix: clients/java + - toc: dotnet-client:// + path_prefix: clients/dotnet +``` +→ All clients under `/clients/` + +--- + +## What You Can Reference + +In `navigation.yml`, you can reference: + +**Entire repositories:** +```yaml +- toc: elastic-project:// + path_prefix: elasticsearch +``` + +**Individual TOC sections:** +```yaml +- toc: elastic-project://api + path_prefix: elasticsearch/api +``` + +**You cannot reference:** +- Individual files +- Folders + +Files and folders are automatically included as children of their parent TOC. + +> **Node type details:** See [Node Types](node-types.md) for complete reference on each navigation node type. + +--- + +## Phantom Nodes + +Acknowledge content exists without including it in the site: + +```yaml +# navigation.yml +phantoms: + - source: plugins:// + - source: cloud://monitoring +``` + +**Use for:** +- Work-in-progress content +- Legacy content being phased out +- External content that's cross-linked but not hosted + +This prevents "undeclared navigation" warnings. + +--- + +## Summary + +**Isolated builds:** One repository → one navigation tree (default prefix `/`) + +**Assembler builds:** Multiple repositories → one site with custom URL prefixes + +**The key insight:** Same navigation structure, different URLs. Re-homing changes URL prefixes without rebuilding the tree. + +--- + +## Learn More + +- **[First Principles](first-principles.md)** - Core design decisions +- **[Assembler Process](assembler-process.md)** - Four-phase assembly explained +- **[Home Provider Architecture](home-provider-architecture.md)** - How O(1) re-homing works +- **[Node Types](node-types.md)** - Complete reference for each node type +- **[Two-Phase Loading](two-phase-loading.md)** - Configuration vs navigation construction diff --git a/docs/development/toc.yml b/docs/development/toc.yml index 8e571cd12..58a8dc394 100644 --- a/docs/development/toc.yml +++ b/docs/development/toc.yml @@ -3,4 +3,16 @@ toc: - folder: ingest children: - file: index.md + - folder: navigation + children: + - file: navigation.md + - file: visual-walkthrough.md + - file: first-principles.md + children: + - file: functional-principles.md + - file: technical-principles.md + - file: two-phase-loading.md + - file: node-types.md + - file: home-provider-architecture.md + - file: assembler-process.md - toc: link-validation diff --git a/docs/testing/cross-links.md b/docs/testing/cross-links.md index 4f0863d4c..e55ce8ded 100644 --- a/docs/testing/cross-links.md +++ b/docs/testing/cross-links.md @@ -1,7 +1,5 @@ # Cross Links -[Elasticsearch](docs-content://index.md) - -[Kibana][1] +[docs-content](docs-content://index.md) [1]: docs-content://index.md diff --git a/src/Elastic.ApiExplorer/ApiIndexLeafNavigation.cs b/src/Elastic.ApiExplorer/ApiIndexLeafNavigation.cs new file mode 100644 index 000000000..b9e803aa7 --- /dev/null +++ b/src/Elastic.ApiExplorer/ApiIndexLeafNavigation.cs @@ -0,0 +1,36 @@ +// 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 Elastic.Documentation.Navigation; + +namespace Elastic.ApiExplorer; + +public class ApiIndexLeafNavigation( + TModel model, string url, string navigationTitle, + IRootNavigationItem rootNavigation, + INodeNavigationItem? parent = null +) : ILeafNavigationItem + where TModel : IApiModel +{ + /// + public string Url { get; } = url; + + /// + public string NavigationTitle { get; } = navigationTitle; + + /// + public IRootNavigationItem NavigationRoot { get; } = rootNavigation; + + /// + public INodeNavigationItem? Parent { get; set; } = parent; + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + /// + public TModel Model { get; } = model; +} diff --git a/src/Elastic.ApiExplorer/ApiRenderContext.cs b/src/Elastic.ApiExplorer/ApiRenderContext.cs index 0a60880e6..749ebc42a 100644 --- a/src/Elastic.ApiExplorer/ApiRenderContext.cs +++ b/src/Elastic.ApiExplorer/ApiRenderContext.cs @@ -4,6 +4,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; using Microsoft.OpenApi.Models; diff --git a/src/Elastic.ApiExplorer/ApiViewModel.cs b/src/Elastic.ApiExplorer/ApiViewModel.cs index 0cc7d464e..048190738 100644 --- a/src/Elastic.ApiExplorer/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/ApiViewModel.cs @@ -7,9 +7,9 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site; using Elastic.Documentation.Site.FileProviders; -using Elastic.Documentation.Site.Navigation; using Microsoft.AspNetCore.Html; namespace Elastic.ApiExplorer; diff --git a/src/Elastic.ApiExplorer/Endpoints/ApiEndpoint.cs b/src/Elastic.ApiExplorer/Endpoints/ApiEndpoint.cs index 4e918aacc..b79b83b14 100644 --- a/src/Elastic.ApiExplorer/Endpoints/ApiEndpoint.cs +++ b/src/Elastic.ApiExplorer/Endpoints/ApiEndpoint.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.Navigation; using Microsoft.OpenApi.Models.Interfaces; using RazorSlices; diff --git a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs index 9c636420e..0c21b61ec 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs @@ -5,7 +5,7 @@ using System.IO.Abstractions; using Elastic.ApiExplorer.Operations; using Elastic.Documentation.Extensions; -using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Navigation; using RazorSlices; namespace Elastic.ApiExplorer.Landing; @@ -28,32 +28,29 @@ public class LandingNavigationItem : IApiGroupingNavigationItem NavigationRoot { get; } public string Id { get; } - public int Depth { get; } - public ApiLanding Index { get; } + public ILeafNavigationItem Index { get; } public IReadOnlyCollection NavigationItems { get; set; } = []; public INodeNavigationItem? Parent { get; set; } public int NavigationIndex { get; set; } - public bool IsCrossLink => false; // API landing items are never cross-links - public string Url { get; } + public string Url => Index.Url; public bool Hidden => false; + public Uri Identifier { get; } = new Uri("todo://"); - //TODO - public string NavigationTitle { get; } = "API Overview"; + public string NavigationTitle => Index.NavigationTitle; public LandingNavigationItem(string url) { - Depth = 0; NavigationRoot = this; Id = ShortId.Create("root"); - var landing = new ApiLanding(); - Url = url; - - Index = landing; + Index = new ApiIndexLeafNavigation(landing, url, "Api Overview", this); } /// public bool IsUsingNavigationDropdown => false; + + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => + throw new NotSupportedException($"{nameof(IAssignableChildrenNavigation.SetNavigationItems)} is not supported on ${nameof(ClassificationNavigationItem)}"); } public interface IApiGroupingNavigationItem : INodeNavigationItem @@ -63,10 +60,12 @@ public interface IApiGroupingNavigationItem( TGroupingModel groupingModel, IRootNavigationItem rootNavigation, - INodeNavigationItem parent) + INodeNavigationItem parent +) : IApiGroupingNavigationItem where TGroupingModel : IApiGroupingModel where TNavigationItem : INavigationItem + { /// public string Url => NavigationItems.First().Url; @@ -84,15 +83,15 @@ public abstract class ApiGroupingNavigationItem public bool Hidden => false; /// public int NavigationIndex { get; set; } - public bool IsCrossLink => false; // API grouping items are never cross-links - /// - public int Depth => 0; + public Uri Identifier { get; } = new Uri("todo://"); /// public abstract string Id { get; } + + //TODO ensure Index is not newed everytime /// - public TGroupingModel Index { get; } = groupingModel; + public ILeafNavigationItem Index => new ApiIndexLeafNavigation(groupingModel, Url, NavigationTitle, rootNavigation, Parent); /// public IReadOnlyCollection NavigationItems { get; set; } = []; @@ -109,6 +108,9 @@ public class ClassificationNavigationItem(ApiClassification classification, Land /// public bool IsUsingNavigationDropdown => false; + + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => + throw new NotSupportedException($"{nameof(IAssignableChildrenNavigation.SetNavigationItems)} is not supported on ${nameof(ClassificationNavigationItem)}"); } public class TagNavigationItem(ApiTag tag, IRootNavigationItem rootNavigation, INodeNavigationItem parent) @@ -143,16 +145,13 @@ public class EndpointNavigationItem(ApiEndpoint endpoint, IRootNavigationItem public int NavigationIndex { get; set; } - public bool IsCrossLink => false; // API endpoint items are never cross-links - - /// - public int Depth => 0; /// public string Id { get; } = ShortId.Create(nameof(EndpointNavigationItem), endpoint.Operations.First().ApiName, endpoint.Operations.First().Route); + //TODO ensure Index is not newed everytime /// - public ApiEndpoint Index { get; } = endpoint; + public ILeafNavigationItem Index => new ApiIndexLeafNavigation(endpoint, Url, NavigationTitle, rootNavigation, Parent); /// public IReadOnlyCollection NavigationItems { get; set; } = []; diff --git a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml index 8d81b5502..f3fd31b30 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml +++ b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml @@ -1,6 +1,7 @@ @inherits RazorSliceHttpResult @using Elastic.ApiExplorer.Landing @using Elastic.ApiExplorer.Operations +@using Elastic.Documentation.Navigation @using Elastic.Documentation.Site.Navigation @implements IUsesLayout @functions { diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 50860c849..639346e04 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -8,6 +8,7 @@ using Elastic.ApiExplorer.Operations; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; using Microsoft.Extensions.Logging; @@ -265,7 +266,7 @@ public async Task Generate(Cancel ctx = default) CurrentNavigation = navigation, MarkdownRenderer = markdownStringRenderer }; - _ = await Render(prefix, navigation, navigation.Index, renderContext, navigationRenderer, ctx); + _ = await Render(prefix, navigation, navigation.Index.Model, renderContext, navigationRenderer, ctx); await RenderNavigationItems(prefix, renderContext, navigationRenderer, navigation, ctx); } @@ -275,7 +276,7 @@ private async Task RenderNavigationItems(string prefix, ApiRenderContext renderC { if (currentNavigation is INodeNavigationItem node) { - _ = await Render(prefix, node, node.Index, renderContext, navigationRenderer, ctx); + _ = await Render(prefix, node, node.Index.Model, renderContext, navigationRenderer, ctx); foreach (var child in node.NavigationItems) await RenderNavigationItems(prefix, renderContext, navigationRenderer, child, ctx); } @@ -298,7 +299,7 @@ private async Task Render(string prefix, INavigationItem current, if (!outputFile.Directory!.Exists) outputFile.Directory.Create(); - var navigationRenderResult = await navigationRenderer.RenderNavigation(current.NavigationRoot, INavigationHtmlWriter.AllLevels, ctx); + var navigationRenderResult = await navigationRenderer.RenderNavigation(current.NavigationRoot, current, INavigationHtmlWriter.AllLevels, ctx); renderContext = renderContext with { CurrentNavigation = current, diff --git a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs index 1c0f51fdb..27ec0a8c9 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs @@ -5,7 +5,7 @@ using System.IO.Abstractions; using Elastic.ApiExplorer.Landing; using Elastic.Documentation.Extensions; -using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Navigation; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models.Interfaces; using RazorSlices; @@ -60,7 +60,6 @@ IApiGroupingNavigationItem parent public IRootNavigationItem NavigationRoot { get; } //TODO enum to string public string Id { get; } - public int Depth { get; } = 1; public ApiOperation Model { get; } public string Url { get; } public bool Hidden { get; set; } @@ -70,6 +69,5 @@ IApiGroupingNavigationItem parent public INodeNavigationItem? Parent { get; set; } public int NavigationIndex { get; set; } - public bool IsCrossLink => false; // API operations are never cross-links } diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index e1c1c93d0..0b650a703 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Synonyms; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; @@ -29,6 +30,8 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public ConfigurationFile Configuration { get; } + public DocumentationSetFile ConfigurationYaml { get; set; } + public VersionsConfiguration VersionsConfiguration { get; } public ConfigurationFileProvider ConfigurationFileProvider { get; } public DocumentationEndpoints Endpoints { get; } @@ -110,10 +113,17 @@ public BuildContext( DocumentationSourceDirectory = ConfigurationPath.Directory!; Git = gitCheckoutInformation ?? GitCheckoutInformation.Create(DocumentationCheckoutDirectory, ReadFileSystem); - Configuration = new ConfigurationFile(this, VersionsConfiguration, ProductsConfiguration); + + // Load and resolve the docset file, or create an empty one if it doesn't exist + ConfigurationYaml = ConfigurationPath.Exists + ? DocumentationSetFile.LoadAndResolve(collector, ConfigurationPath, readFileSystem) + : new DocumentationSetFile(); + + Configuration = new ConfigurationFile(ConfigurationYaml, this, VersionsConfiguration, ProductsConfiguration); GoogleTagManager = new GoogleTagManagerConfiguration { Enabled = false }; } + } diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index a5b1c94de..8b3cf896f 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -5,16 +5,13 @@ using System.IO.Abstractions; using DotNet.Globbing; using Elastic.Documentation.Configuration.Products; -using Elastic.Documentation.Configuration.Suggestions; -using Elastic.Documentation.Configuration.TableOfContents; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Links; -using Elastic.Documentation.Navigation; -using YamlDotNet.RepresentationModel; namespace Elastic.Documentation.Configuration.Builder; -public record ConfigurationFile : ITableOfContentsScope +public record ConfigurationFile { private readonly IDocumentationSetContext _context; @@ -31,18 +28,10 @@ public record ConfigurationFile : ITableOfContentsScope public EnabledExtensions Extensions { get; } = new([]); - public IReadOnlyCollection TableOfContents { get; } = []; - - public HashSet Files { get; } = new(StringComparer.OrdinalIgnoreCase); - public Dictionary? Redirects { get; } public HashSet Products { get; } = []; - public HashSet ImplicitFolders { get; } = new(StringComparer.OrdinalIgnoreCase); - - public Glob[] Globs { get; } = []; - private readonly Dictionary _substitutions = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyDictionary Substitutions => _substitutions; @@ -54,16 +43,11 @@ public record ConfigurationFile : ITableOfContentsScope public IReadOnlyDictionary? OpenApiSpecifications { get; } - /// This is a documentation set that is not linked to by assembler. + /// This is a documentation set not linked to by assembler. /// Setting this to true relaxes a few restrictions such as mixing toc references with file and folder reference public bool DevelopmentDocs { get; } - // TODO ensure project key is `docs-content` - public bool IsNarrativeDocs => - Project is not null - && Project.Equals("Elastic documentation", StringComparison.OrdinalIgnoreCase); - - public ConfigurationFile(IDocumentationSetContext context, VersionsConfiguration versionsConfig, ProductsConfiguration productsConfig) + public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetContext context, VersionsConfiguration versionsConfig, ProductsConfiguration productsConfig) { _context = context; ScopeDirectory = context.ConfigurationPath.Directory!; @@ -74,99 +58,51 @@ public ConfigurationFile(IDocumentationSetContext context, VersionsConfiguration return; } + var redirectFile = new RedirectFile(_context); Redirects = redirectFile.Redirects; - var sourceFile = context.ConfigurationPath; - var reader = new YamlStreamReader(sourceFile, _context.Collector); try { - foreach (var entry in reader.Read()) + // Read values from DocumentationSetFile + Project = docSetFile.Project; + MaxTocDepth = docSetFile.MaxTocDepth; + DevelopmentDocs = docSetFile.DevDocs; + + // Convert exclude patterns to Glob + Exclude = [.. docSetFile.Exclude.Where(s => !string.IsNullOrEmpty(s)).Select(Glob.Parse)]; + + // Set cross link repositories + CrossLinkRepositories = [.. docSetFile.CrossLinks]; + + // Extensions - assuming they're not in DocumentationSetFile yet + Extensions = new EnabledExtensions(docSetFile.Extensions); + + // Read substitutions + _substitutions = new(docSetFile.Subs, StringComparer.OrdinalIgnoreCase); + + // Process API specifications + if (docSetFile.Api.Count > 0) { - switch (entry.Key) + var specs = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (k, v) in docSetFile.Api) { - case "project": - Project = reader.ReadString(entry.Entry); - break; - case "max_toc_depth": - MaxTocDepth = int.TryParse(reader.ReadString(entry.Entry), out var maxTocDepth) ? maxTocDepth : 1; - break; - case "dev_docs": - DevelopmentDocs = bool.TryParse(reader.ReadString(entry.Entry), out var devDocs) && devDocs; - break; - case "exclude": - var excludes = YamlStreamReader.ReadStringArray(entry.Entry); - Exclude = [.. excludes.Where(s => !string.IsNullOrEmpty(s)).Select(Glob.Parse)]; - break; - case "cross_links": - CrossLinkRepositories = [.. YamlStreamReader.ReadStringArray(entry.Entry)]; - break; - case "extensions": - Extensions = new([.. YamlStreamReader.ReadStringArray(entry.Entry)]); - break; - case "subs": - _substitutions = reader.ReadDictionary(entry.Entry); - break; - case "toc": - // read this later - break; - case "api": - var configuredApis = reader.ReadDictionary(entry.Entry); - if (configuredApis.Count == 0) - break; - - var specs = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (k, v) in configuredApis) - { - var path = Path.Combine(context.DocumentationSourceDirectory.FullName, v); - var fi = context.ReadFileSystem.FileInfo.New(path); - specs[k] = fi; - } - OpenApiSpecifications = specs; - break; - case "products": - if (entry.Entry.Value is not YamlSequenceNode sequence) - { - reader.EmitError("products must be a sequence", entry.Entry.Value); - break; - } - - foreach (var node in sequence.Children.OfType()) - { - YamlScalarNode? productId = null; - - foreach (var child in node.Children) - { - if (child is { Key: YamlScalarNode { Value: "id" }, Value: YamlScalarNode scalarNode }) - { - productId = scalarNode; - break; - } - } - if (productId?.Value is null) - { - reader.EmitError("products must contain an id", node); - break; - } - - if (!productsConfig.Products.TryGetValue(productId.Value.Replace('_', '-'), out var productToAdd)) - reader.EmitError($"Product \"{productId.Value}\" not found in the product list. {new Suggestion(productsConfig.Products.Select(p => p.Value.Id).ToHashSet(), productId.Value).GetSuggestionQuestion()}", node); - else - _ = Products.Add(productToAdd); - } - break; - case "features": - _features = reader.ReadDictionary(entry.Entry).ToDictionary(k => k.Key, v => bool.Parse(v.Value), StringComparer.OrdinalIgnoreCase); - break; - case "external_hosts": - reader.EmitWarning($"{entry.Key} has been deprecated and will be removed", entry.Key); - break; - default: - reader.EmitWarning($"{entry.Key} is not a known configuration", entry.Key); - break; + var path = Path.Combine(context.DocumentationSourceDirectory.FullName, v); + var fi = context.ReadFileSystem.FileInfo.New(path); + specs[k] = fi; } + OpenApiSpecifications = specs; } + // Process products - need to parse from docSetFile if they exist + // Note: Products parsing would need to be added to DocumentationSetFile if needed + + // Process features + _features = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (docSetFile.Features.PrimaryNav.HasValue) + _features["primary-nav"] = docSetFile.Features.PrimaryNav.Value; + + // Add version substitutions foreach (var (id, system) in versionsConfig.VersioningSystems) { var name = id.ToStringFast(true); @@ -177,6 +113,7 @@ public ConfigurationFile(IDocumentationSetContext context, VersionsConfiguration _substitutions[$"version.{alternativeName}.base"] = system.Base; } + // Add product substitutions foreach (var product in productsConfig.Products.Values) { var alternativeProductId = product.Id.Replace('-', '_'); @@ -185,18 +122,12 @@ public ConfigurationFile(IDocumentationSetContext context, VersionsConfiguration _substitutions[$"product.{alternativeProductId}"] = product.DisplayName; _substitutions[$".{alternativeProductId}"] = product.DisplayName; } - - var toc = new TableOfContentsConfiguration(this, sourceFile, ScopeDirectory, _context, 0, ""); - TableOfContents = toc.TableOfContents; - Files = toc.Files; } catch (Exception e) { - reader.EmitError("Could not load docset.yml", e); + context.EmitError(context.ConfigurationPath, $"Could not load docset.yml: {e.Message}"); throw; } - - Globs = [.. ImplicitFolders.Select(f => Glob.Parse($"{f}{Path.DirectorySeparatorChar}*.md"))]; } } diff --git a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs b/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs deleted file mode 100644 index 346734fbe..000000000 --- a/src/Elastic.Documentation.Configuration/Builder/TableOfContentsConfiguration.cs +++ /dev/null @@ -1,345 +0,0 @@ -// 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; -using System.Runtime.InteropServices; -using Elastic.Documentation.Configuration.Plugins.DetectionRules.TableOfContents; -using Elastic.Documentation.Configuration.TableOfContents; -using Elastic.Documentation.Links; -using Elastic.Documentation.Navigation; -using YamlDotNet.RepresentationModel; - -namespace Elastic.Documentation.Configuration.Builder; - -public record TableOfContentsConfiguration : ITableOfContentsScope -{ - private readonly IDocumentationSetContext _context; - private readonly int _maxTocDepth; - private readonly int _depth; - private readonly string _parentPath; - private readonly IDirectoryInfo _rootPath; - private readonly ConfigurationFile _configuration; - - public Uri Source { get; } - - public HashSet Files { get; } = new(StringComparer.OrdinalIgnoreCase); - - public IReadOnlyCollection TableOfContents { get; private set; } = []; - - public IFileInfo DefinitionFile { get; } - public IDirectoryInfo ScopeDirectory { get; } - - public TableOfContentsConfiguration( - ConfigurationFile configuration, - IFileInfo definitionFile, - IDirectoryInfo scope, - IDocumentationSetContext context, - int depth, - string parentPath) - { - _configuration = configuration; - DefinitionFile = definitionFile; - ScopeDirectory = scope; - _maxTocDepth = configuration.MaxTocDepth; - _rootPath = context.DocumentationSourceDirectory; - _context = context; - _depth = depth; - _parentPath = parentPath; - - var tocPath = scope.FullName; - var relativePath = Path.GetRelativePath(context.DocumentationSourceDirectory.FullName, tocPath); - var moniker = ContentSourceMoniker.Create(context.Git.RepositoryName, relativePath); - Source = moniker; - - TableOfContents = ReadChildren(); - - } - - private IReadOnlyCollection ReadChildren() - { - if (!DefinitionFile.Exists) - return []; - var reader = new YamlStreamReader(DefinitionFile, _context.Collector); - foreach (var entry in reader.Read()) - { - switch (entry.Key) - { - case "toc": - var children = ReadChildren(reader, entry.Entry); - var tocEntries = TableOfContents.OfType().ToArray(); - - // if no nested toc sections simply return - if (tocEntries.Length == 0) - return children; - - // dev docs may mix and match as they please because they publish in isolation - if (_configuration.DevelopmentDocs) - return children; - - // narrative docs may put files at the root as they please. - if (_configuration.IsNarrativeDocs && _depth == 0) - return children; - - var filePaths = children.OfType().ToArray(); - if (filePaths.Length == 0 && _depth == 0) - return children; - if (filePaths.Length is > 1 or 0) - reader.EmitError("toc with nested toc sections must only link a single file: index.md", entry.Key); - else if (!filePaths[0].RelativePath.EndsWith("index.md", StringComparison.OrdinalIgnoreCase)) - reader.EmitError($"toc with nested toc sections must only link a single file: 'index.md' actually linked {filePaths[0].RelativePath}", entry.Key); - return children; - } - } - - - return []; - } - - private IReadOnlyCollection ReadChildren(YamlStreamReader reader, KeyValuePair entry, string? parentPath = null) - { - parentPath ??= _parentPath; - if (_depth > _maxTocDepth) - { - reader.EmitError($"toc.yml files may not be linked deeper than {_maxTocDepth} current depth {_depth}", entry.Key); - return []; - } - - var entries = new List(); - if (entry.Value is not YamlSequenceNode sequence) - { - if (entry.Key is YamlScalarNode scalarKey) - { - var key = scalarKey.Value; - reader.EmitWarning($"'{key}' is not an array"); - } - else - reader.EmitWarning($"'{entry.Key}' is not an array"); - - return entries; - } - - entries.AddRange( - sequence.Children.OfType() - .SelectMany(tocEntry => ReadChild(reader, tocEntry, parentPath) ?? []) - ); - TableOfContents = entries; - return entries; - } - - private IEnumerable? ReadChild(YamlStreamReader reader, YamlMappingNode tocEntry, string parentPath) - { - string? file = null; - string? crossLink = null; - string? title = null; - string? folder = null; - string[]? detectionRules = null; - TableOfContentsConfiguration? toc = null; - var detectionRulesFound = false; - var hiddenFile = false; - IReadOnlyCollection? children = null; - foreach (var entry in tocEntry.Children) - { - var key = ((YamlScalarNode)entry.Key).Value; - switch (key) - { - case "toc": - toc = ReadNestedToc(reader, entry, parentPath); - break; - case "hidden": - case "file": - hiddenFile = key == "hidden"; - file = ReadFile(reader, entry, parentPath); - break; - case "title": - title = reader.ReadString(entry); - break; - case "crosslink": - hiddenFile = false; - crossLink = reader.ReadString(entry); - // Validate crosslink URI early - if (!CrossLinkValidator.IsValidCrossLink(crossLink, out var errorMessage)) - { - reader.EmitError(errorMessage!, tocEntry); - crossLink = null; // Reset to prevent further processing - } - break; - case "folder": - folder = ReadFolder(reader, entry, parentPath); - parentPath += $"{Path.DirectorySeparatorChar}{folder}"; - break; - case "detection_rules": - if (_configuration.Extensions.IsDetectionRulesEnabled) - { - detectionRules = ReadDetectionRules(reader, entry, parentPath, out detectionRulesFound); - parentPath += $"{Path.DirectorySeparatorChar}{folder}"; - } - break; - case "children": - children = ReadChildren(reader, entry, parentPath); - break; - } - } - - // Validate that crosslink entries have titles - if (crossLink is not null && string.IsNullOrWhiteSpace(title)) - { - reader.EmitError($"Cross-link entries must have a 'title' specified. Cross-link: {crossLink}", tocEntry); - return null; - } - - // Validate that standalone titles (without content) are not allowed - if (!string.IsNullOrWhiteSpace(title) && - file is null && crossLink is null && folder is null && toc is null && - (detectionRules is null || detectionRules.Length == 0)) - { - reader.EmitError($"Table of contents entries with only a 'title' are not allowed. Entry must specify content (file, crosslink, folder, or toc). Title: '{title}'", tocEntry); - return null; - } - - if (toc is not null) - { - foreach (var f in toc.Files) - _ = Files.Add(f); - - return [new TocReference(toc.Source, toc, $"{parentPath}".TrimStart(Path.DirectorySeparatorChar), toc.TableOfContents)]; - } - - if (file is not null) - { - if (detectionRules is not null) - { - if (children is not null) - reader.EmitError($"'detection_rules' is not allowed to have 'children'", tocEntry); - - if (!detectionRulesFound) - { - reader.EmitError($"'detection_rules' folder {parentPath} is not found, skipping'", tocEntry); - children = []; - } - else - { - var overviewPath = $"{parentPath}{Path.DirectorySeparatorChar}{file}".TrimStart(Path.DirectorySeparatorChar); - var landingPage = new RuleOverviewReference(this, overviewPath, parentPath, _configuration, _context, detectionRules); - foreach (var child in landingPage.Children.OfType()) - _ = Files.Add(child.RelativePath); - return [landingPage]; - } - } - - var path = $"{parentPath}{Path.DirectorySeparatorChar}{file}".TrimStart(Path.DirectorySeparatorChar); - return [new FileReference(this, path, hiddenFile, children ?? [])]; - } - - if (crossLink is not null) - { - if (Uri.TryCreate(crossLink, UriKind.Absolute, out var crossUri) && CrossLinkValidator.IsCrossLink(crossUri)) - return [new CrossLinkReference(this, crossUri, title, hiddenFile, children ?? [])]; - else - reader.EmitError($"Cross-link '{crossLink}' is not a valid absolute URI format", tocEntry); - } - - if (folder is not null) - { - if (children is null) - _ = _configuration.ImplicitFolders.Add(parentPath.TrimStart(Path.DirectorySeparatorChar)); - - return [new FolderReference(this, $"{parentPath}".TrimStart(Path.DirectorySeparatorChar), children ?? [])]; - } - - return null; - } - - private string? ReadFolder(YamlStreamReader reader, KeyValuePair entry, string parentPath) - { - var folder = reader.ReadString(entry); - if (folder is null) - return folder; - - var path = Path.Combine(_rootPath.FullName, parentPath.TrimStart(Path.DirectorySeparatorChar), folder); - if (!_context.ReadFileSystem.DirectoryInfo.New(path).Exists) - reader.EmitError($"Directory '{path}' does not exist", entry.Key); - - return folder; - } - - private string[]? ReadDetectionRules(YamlStreamReader reader, KeyValuePair entry, string parentPath, out bool found) - { - found = false; - var folders = YamlStreamReader.ReadStringArray(entry); - foreach (var folder in folders) - { - if (string.IsNullOrWhiteSpace(folder)) - continue; - - var path = Path.Combine(_rootPath.FullName, parentPath.TrimStart(Path.DirectorySeparatorChar), folder); - if (!_context.ReadFileSystem.DirectoryInfo.New(path).Exists) - reader.EmitError($"Directory '{path}' does not exist", entry.Key); - else - found = true; - - } - return folders.Length == 0 ? null : folders; - } - - private string? ReadFile(YamlStreamReader reader, KeyValuePair entry, string parentPath) - { - var file = reader.ReadString(entry); - if (file is null) - return null; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - file = file.Replace('/', Path.DirectorySeparatorChar); - - var path = Path.Combine(_rootPath.FullName, parentPath.TrimStart(Path.DirectorySeparatorChar), file); - if (!_context.ReadFileSystem.FileInfo.New(path).Exists) - reader.EmitError($"File '{path}' does not exist", entry.Key); - _ = Files.Add(Path.Combine(parentPath, file).TrimStart(Path.DirectorySeparatorChar)); - - return file; - } - - private TableOfContentsConfiguration? ReadNestedToc(YamlStreamReader reader, KeyValuePair entry, string parentPath) - { - var found = false; - var tocPath = reader.ReadString(entry); - if (tocPath is null) - { - reader.EmitError($"Empty toc: reference", entry.Key); - return null; - } - var fullTocPath = Path.Combine(parentPath, tocPath); - - var rootPath = _context.ReadFileSystem.DirectoryInfo.New(Path.Combine(_rootPath.FullName, fullTocPath)); - var path = Path.Combine(rootPath.FullName, "toc.yml"); - var source = _context.ReadFileSystem.FileInfo.New(path); - - var errorMessage = $"Nested toc: '{source.Directory}' directory has no toc.yml or _toc.yml file"; - - if (!source.Exists) - { - path = Path.Combine(rootPath.FullName, "_toc.yml"); - source = _context.ReadFileSystem.FileInfo.New(path); - } - - if (!source.Exists) - reader.EmitError(errorMessage, entry.Key); - else - found = true; - - if (!found) - return null; - - var tocYamlReader = new YamlStreamReader(source, _context.Collector); - foreach (var kv in tocYamlReader.Read()) - { - switch (kv.Key) - { - case "toc": - var nestedConfiguration = new TableOfContentsConfiguration(_configuration, source, source.Directory!, _context, _depth + 1, fullTocPath); - _ = nestedConfiguration.ReadChildren(reader, kv.Entry); - return nestedConfiguration; - } - } - return null; - } -} diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index b20111fad..bc0909aac 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -5,7 +5,9 @@ using System.IO.Abstractions; using System.Text.RegularExpressions; using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.Converters; using Elastic.Documentation.Configuration.Serialization; +using Elastic.Documentation.Configuration.Toc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using YamlDotNet.Serialization; @@ -19,8 +21,13 @@ public partial class ConfigurationFileProvider private readonly string _assemblyName; private readonly ILogger _logger; - internal static IDeserializer Deserializer { get; } = new StaticDeserializerBuilder(new YamlStaticContext()) + public static IDeserializer Deserializer { get; } = new StaticDeserializerBuilder(new YamlStaticContext()) .WithNamingConvention(UnderscoredNamingConvention.Instance) + .WithTypeConverter(new HintTypeSetConverter()) + .WithTypeConverter(new TocItemCollectionYamlConverter()) + .WithTypeConverter(new TocItemYamlConverter()) + .WithTypeConverter(new SiteTableOfContentsCollectionYamlConverter()) + .WithTypeConverter(new SiteTableOfContentsRefYamlConverter()) .Build(); public ConfigurationSource ConfigurationSource { get; } diff --git a/src/Elastic.Documentation.Configuration/Converters/HintTypeSetConverter.cs b/src/Elastic.Documentation.Configuration/Converters/HintTypeSetConverter.cs new file mode 100644 index 000000000..eb2aa886d --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Converters/HintTypeSetConverter.cs @@ -0,0 +1,62 @@ +// 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 Elastic.Documentation.Diagnostics; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace Elastic.Documentation.Configuration.Converters; + +/// +/// YAML converter for deserializing a list of strings into a HashSet of HintType enums. +/// +public class HintTypeSetConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(HashSet); + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + var result = new HashSet(); + + // Handle null/empty case + if (parser.Current is not SequenceStart) + { + _ = parser.MoveNext(); + return result; + } + + _ = parser.MoveNext(); // Skip SequenceStart + + while (parser.Current is not SequenceEnd) + { + if (parser.Current is Scalar scalar) + { + var value = scalar.Value; + if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse(value, ignoreCase: true, out var hintType)) + _ = result.Add(hintType); + } + _ = parser.MoveNext(); + } + + _ = parser.MoveNext(); // Skip SequenceEnd + + return result; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) + { + if (value is not HashSet set) + { + emitter.Emit(new SequenceStart(null, null, false, SequenceStyle.Block)); + emitter.Emit(new SequenceEnd()); + return; + } + + emitter.Emit(new SequenceStart(null, null, false, SequenceStyle.Block)); + foreach (var hint in set) + emitter.Emit(new Scalar(hint.ToString())); + emitter.Emit(new SequenceEnd()); + } +} diff --git a/src/Elastic.Documentation.Configuration/Navigation/GlobalNavigationFile.cs b/src/Elastic.Documentation.Configuration/Navigation/GlobalNavigationFile.cs deleted file mode 100644 index f659b0fe6..000000000 --- a/src/Elastic.Documentation.Configuration/Navigation/GlobalNavigationFile.cs +++ /dev/null @@ -1,380 +0,0 @@ -// 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.Collections.Frozen; -using System.Collections.Immutable; -using System.IO.Abstractions; -using Elastic.Documentation.Configuration.Assembler; -using Elastic.Documentation.Configuration.Builder; -using Elastic.Documentation.Configuration.TableOfContents; -using Elastic.Documentation.Diagnostics; -using Elastic.Documentation.Navigation; -using YamlDotNet.RepresentationModel; - -namespace Elastic.Documentation.Configuration.Navigation; - -public record NavigationTocMapping -{ - public required Uri Source { get; init; } - public required string SourcePathPrefix { get; init; } - public required Uri TopLevelSource { get; init; } - public required Uri ParentSource { get; init; } -} - -public record TocConfigurationMapping -{ - public required NavigationTocMapping TopLevel { get; init; } - public required ConfigurationFile RepositoryConfigurationFile { get; init; } - public required TableOfContentsConfiguration TableOfContentsConfiguration { get; init; } -} - -public record GlobalNavigationFile : ITableOfContentsScope -{ - //private readonly AssembleContext _context; - private readonly IDiagnosticsCollector _collector; - private readonly ConfigurationFileProvider _configurationFileProvider; - private readonly AssemblyConfiguration _configuration; - - private readonly FrozenDictionary _tocConfigurationMappings; - //private readonly AssembleSources _assembleSources; - - public IReadOnlyCollection TableOfContents { get; } - public IReadOnlyCollection Phantoms { get; } - - public IDirectoryInfo ScopeDirectory { get; } - - public GlobalNavigationFile( - IDiagnosticsCollector collector, - ConfigurationFileProvider configurationFileProvider, - AssemblyConfiguration configuration, - FrozenDictionary tocConfigurationMappings - ) - { - //_context = context; - _collector = collector; - _configurationFileProvider = configurationFileProvider; - _configuration = configuration; - _tocConfigurationMappings = tocConfigurationMappings; - NavigationFile = configurationFileProvider.CreateNavigationFile(configuration); - TableOfContents = Deserialize("toc"); - Phantoms = Deserialize("phantoms"); - ScopeDirectory = NavigationFile.Directory!; - } - - private IFileInfo NavigationFile { get; } - - public static bool ValidatePathPrefixes( - IDiagnosticsCollector collector, - ConfigurationFileProvider configurationFileProvider, - AssemblyConfiguration configuration - ) - { - var sourcePathPrefixes = GetAllPathPrefixes(collector, configurationFileProvider, configuration); - var pathPrefixSet = new HashSet(); - var valid = true; - foreach (var pathPrefix in sourcePathPrefixes) - { - var prefix = $"{pathPrefix.Host}/{pathPrefix.AbsolutePath.Trim('/')}/"; - if (pathPrefixSet.Add(prefix)) - continue; - var duplicateOf = sourcePathPrefixes.First(p => p.Host == pathPrefix.Host && p.AbsolutePath == pathPrefix.AbsolutePath); - collector.EmitError(configurationFileProvider.NavigationFile, $"Duplicate path prefix: {pathPrefix} duplicate: {duplicateOf}"); - valid = false; - } - return valid; - } - - - public static ImmutableHashSet GetAllPathPrefixes( - IDiagnosticsCollector collector, - ConfigurationFileProvider configurationFileProvider, - AssemblyConfiguration configuration - ) => - GetSourceUris("toc", collector, configurationFileProvider, configuration); - - public static ImmutableHashSet GetPhantomPrefixes( - IDiagnosticsCollector collector, - ConfigurationFileProvider configurationFileProvider, - AssemblyConfiguration configuration - ) => - GetSourceUris("phantoms", collector, configurationFileProvider, configuration); - - private static ImmutableHashSet GetSourceUris( - string key, - IDiagnosticsCollector collector, - ConfigurationFileProvider configurationFileProvider, - AssemblyConfiguration configuration - ) - { - var navigationFile = configurationFileProvider.CreateNavigationFile(configuration); - var reader = new YamlStreamReader(navigationFile, collector); - var set = new HashSet(); - foreach (var entry in reader.Read()) - { - if (entry.Key == key && key == "toc") - ReadPathPrefixes(reader, entry.Entry, set); - if (entry.Key == key && key == "phantoms") - ReadPhantomTocs(reader, entry.Entry, set); - } - return set.ToImmutableHashSet(); - - static void ReadPhantomTocs(YamlStreamReader reader, KeyValuePair entry, HashSet hashSet) - { - if (entry.Value is not YamlSequenceNode sequence) - { - reader.EmitWarning($"'{entry.Value}' is not an array"); - return; - } - - foreach (var tocEntry in sequence.Children.OfType()) - { - foreach (var child in tocEntry.Children) - { - var key = ((YamlScalarNode)child.Key).Value; - switch (key) - { - case "toc": - var source = reader.ReadString(child); - if (source != null && !source.Contains("://")) - source = ContentSourceMoniker.CreateString(NarrativeRepository.RepositoryName, source); - if (source is not null) - _ = hashSet.Add(new Uri(source)); - break; - } - } - } - } - - static void ReadPathPrefixes(YamlStreamReader reader, KeyValuePair entry, HashSet hashSet, string? parent = null) - { - if (entry.Key is not YamlScalarNode { Value: not null } scalarKey) - { - reader.EmitWarning($"key '{entry.Key}' is not string"); - return; - } - - if (entry.Value is not YamlSequenceNode sequence) - { - reader.EmitWarning($"'{scalarKey.Value}' is not an array"); - return; - } - - foreach (var tocEntry in sequence.Children.OfType()) - { - var source = ReadToc(reader, tocEntry, ref parent, out var pathPrefix, out var sourceUri); - if (sourceUri is not null && pathPrefix is not null) - { - var pathUri = new Uri($"{sourceUri.Scheme}://{pathPrefix.TrimEnd('/')}/"); - if (!hashSet.Add(pathUri)) - reader.EmitError($"Duplicate path prefix in the same repository: {pathUri}", tocEntry); - } - - foreach (var child in tocEntry.Children) - { - var key = ((YamlScalarNode)child.Key).Value; - switch (key) - { - case "children": - if (source is null && pathPrefix is null) - { - reader.EmitWarning("toc entry has no toc or path_prefix defined"); - continue; - } - - ReadPathPrefixes(reader, child, hashSet); - break; - } - } - } - } - } - public void EmitWarning(string message) => - _collector.EmitWarning(NavigationFile, message); - - public void EmitError(string message) => - _collector.EmitError(NavigationFile, message); - - private IReadOnlyCollection Deserialize(string key) - { - var navigationFile = _configurationFileProvider.CreateNavigationFile(_configuration); - var reader = new YamlStreamReader(navigationFile, _collector); - try - { - foreach (var entry in reader.Read()) - { - if (entry.Key == key) - return ReadChildren(key, reader, entry.Entry, null, 0); - } - } - catch (Exception e) - { - reader.EmitError("Could not load docset.yml", e); - throw; - } - - return []; - } - - private IReadOnlyCollection ReadChildren(string key, YamlStreamReader reader, KeyValuePair entry, string? parent, - int depth) - { - var entries = new List(); - if (entry.Key is not YamlScalarNode { Value: not null } scalarKey) - { - reader.EmitWarning($"key '{entry.Key}' is not string"); - return []; - } - - if (entry.Value is not YamlSequenceNode sequence) - { - reader.EmitWarning($"'{scalarKey.Value}' is not an array"); - return []; - } - - foreach (var tocEntry in sequence.Children.OfType()) - { - - var child = - key == "toc" - ? ReadTocDefinition(reader, tocEntry, parent, depth) - : ReadPhantomDefinition(reader, tocEntry); - if (child is not null) - entries.Add(child); - } - - return entries; - } - - private TocReference? ReadPhantomDefinition(YamlStreamReader reader, YamlMappingNode tocEntry) - { - foreach (var entry in tocEntry.Children) - { - var key = ((YamlScalarNode)entry.Key).Value; - switch (key) - { - case "toc": - var source = reader.ReadString(entry); - if (source != null && !source.Contains("://")) - source = ContentSourceMoniker.CreateString(NarrativeRepository.RepositoryName, source); - var sourceUri = new Uri(source!); - var tocReference = new TocReference(sourceUri, this, "", []) - { - IsPhantom = true - }; - return tocReference; - } - } - - return null; - } - - private TocReference? ReadTocDefinition(YamlStreamReader reader, YamlMappingNode tocEntry, string? parent, int depth) - { - var source = ReadToc(reader, tocEntry, ref parent, out var pathPrefix, out var sourceUri); - - if (sourceUri is null) - return null; - - - if (!_tocConfigurationMappings.TryGetValue(sourceUri, out var mapping)) - { - reader.EmitError($"Toc entry '{sourceUri}' is could not be located", tocEntry); - return null; - } - - var navigationItems = new List(); - - foreach (var entry in tocEntry.Children) - { - var key = ((YamlScalarNode)entry.Key).Value; - switch (key) - { - case "children": - if (source is null && pathPrefix is null) - { - reader.EmitWarning("toc entry has no toc or path_prefix defined"); - continue; - } - - var children = ReadChildren("toc", reader, entry, parent, depth + 1); - navigationItems.AddRange(children); - break; - } - } - - var rootConfig = mapping.RepositoryConfigurationFile.SourceFile.Directory!; - var path = Path.GetRelativePath(rootConfig.FullName, mapping.TableOfContentsConfiguration.ScopeDirectory.FullName); - var tocReference = new TocReference(sourceUri, mapping.TableOfContentsConfiguration, path, navigationItems); - return tocReference; - } - - private static string? ReadTocSourcePathPrefix(YamlStreamReader reader, YamlMappingNode tocEntry, string? source, out Uri? sourceUri, string? pathPrefix) - { - sourceUri = null; - if (source is null) - return pathPrefix; - - source = source.EndsWith("://", StringComparison.OrdinalIgnoreCase) ? source : source.TrimEnd('/') + "/"; - if (!Uri.TryCreate(source, UriKind.Absolute, out sourceUri)) - { - reader.EmitError($"Source toc entry is not a valid uri: {source}", tocEntry); - return pathPrefix; - } - - var sourcePrefix = $"{sourceUri.Host}/{sourceUri.AbsolutePath.TrimStart('/')}"; - if (string.IsNullOrEmpty(pathPrefix)) - reader.EmitError($"Path prefix is not defined for: {source}, falling back to {sourcePrefix} which may be incorrect", tocEntry); - - pathPrefix ??= sourcePrefix; - return pathPrefix; - } - - private static string? ReadToc( - YamlStreamReader reader, - YamlMappingNode tocEntry, - ref string? parent, - out string? pathPrefix, - out Uri? sourceUri - ) - { - string? repository = null; - string? source = null; - pathPrefix = null; - foreach (var entry in tocEntry.Children) - { - var key = ((YamlScalarNode)entry.Key).Value; - switch (key) - { - case "toc": - source = reader.ReadString(entry); - if (source != null && !source.Contains("://")) - { - parent = source; - pathPrefix = source; - source = ContentSourceMoniker.CreateString(NarrativeRepository.RepositoryName, source); - } - - break; - case "repo": - repository = reader.ReadString(entry); - break; - case "path_prefix": - pathPrefix = reader.ReadString(entry); - break; - } - } - - if (repository is not null) - { - if (source is not null) - reader.EmitError($"toc config defines 'repo' can not be combined with 'toc': {source}", tocEntry); - pathPrefix = string.Join("/", [parent, repository]); - source = ContentSourceMoniker.CreateString(repository, parent); - } - - pathPrefix = ReadTocSourcePathPrefix(reader, tocEntry, source, out sourceUri, pathPrefix); - - return source; - } -} diff --git a/src/Elastic.Documentation.Configuration/Plugins/DetectionRules/TableOfContents/DetectionRulesReference.cs b/src/Elastic.Documentation.Configuration/Plugins/DetectionRules/TableOfContents/DetectionRulesReference.cs deleted file mode 100644 index db0c2e16d..000000000 --- a/src/Elastic.Documentation.Configuration/Plugins/DetectionRules/TableOfContents/DetectionRulesReference.cs +++ /dev/null @@ -1,75 +0,0 @@ -// 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 Elastic.Documentation.Configuration.Builder; -using Elastic.Documentation.Configuration.TableOfContents; -using Elastic.Documentation.Navigation; - -namespace Elastic.Documentation.Configuration.Plugins.DetectionRules.TableOfContents; - -public record RuleOverviewReference : FileReference -{ - - public IReadOnlyCollection DetectionRuleFolders { get; init; } - - private string ParentPath { get; } - - public RuleOverviewReference( - ITableOfContentsScope tableOfContentsScope, - string overviewFilePath, - string parentPath, - ConfigurationFile configuration, - IDocumentationSetContext context, - IReadOnlyCollection detectionRuleFolders - ) - : base(tableOfContentsScope, overviewFilePath, false, []) - { - ParentPath = parentPath; - DetectionRuleFolders = detectionRuleFolders; - Children = CreateTableOfContentItems(configuration, context); - } - - private IReadOnlyCollection CreateTableOfContentItems(ConfigurationFile configuration, IDocumentationSetContext context) - { - var tocItems = new List(); - foreach (var detectionRuleFolder in DetectionRuleFolders) - { - var children = ReadDetectionRuleFolder(configuration, context, detectionRuleFolder); - tocItems.AddRange(children); - } - - return tocItems - .OrderBy(d => d is RuleReference r ? r.Rule.Name : null, StringComparer.OrdinalIgnoreCase) - .ToArray(); - } - - private IReadOnlyCollection ReadDetectionRuleFolder(ConfigurationFile configuration, IDocumentationSetContext context, string detectionRuleFolder) - { - var detectionRulesFolder = Path.Combine(ParentPath, detectionRuleFolder).TrimStart(Path.DirectorySeparatorChar); - var fs = context.ReadFileSystem; - var sourceDirectory = context.DocumentationSourceDirectory; - var path = fs.DirectoryInfo.New(fs.Path.GetFullPath(fs.Path.Combine(sourceDirectory.FullName, detectionRulesFolder))); - IReadOnlyCollection children = path - .EnumerateFiles("*.*", SearchOption.AllDirectories) - .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden) && !f.Attributes.HasFlag(FileAttributes.System)) - .Where(f => !f.Directory!.Attributes.HasFlag(FileAttributes.Hidden) && !f.Directory!.Attributes.HasFlag(FileAttributes.System)) - .Where(f => f.Extension is ".md" or ".toml") - .Where(f => f.Name != "README.md") - .Where(f => !f.FullName.Contains($"{Path.DirectorySeparatorChar}_deprecated{Path.DirectorySeparatorChar}")) - .Select(f => - { - var relativePath = Path.GetRelativePath(sourceDirectory.FullName, f.FullName); - if (f.Extension == ".toml") - { - var rule = DetectionRule.From(f); - return new RuleReference(configuration, relativePath, detectionRuleFolder, true, [], rule); - } - - return new FileReference(configuration, relativePath, false, []); - }) - .ToArray(); - - return children; - } -} diff --git a/src/Elastic.Documentation.Configuration/Plugins/DetectionRules/TableOfContents/RuleReference.cs b/src/Elastic.Documentation.Configuration/Plugins/DetectionRules/TableOfContents/RuleReference.cs deleted file mode 100644 index ba37d4387..000000000 --- a/src/Elastic.Documentation.Configuration/Plugins/DetectionRules/TableOfContents/RuleReference.cs +++ /dev/null @@ -1,17 +0,0 @@ -// 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 Elastic.Documentation.Configuration.TableOfContents; -using Elastic.Documentation.Navigation; - -namespace Elastic.Documentation.Configuration.Plugins.DetectionRules.TableOfContents; - -public record RuleReference( - ITableOfContentsScope TableOfContentsScope, - string RelativePath, - string SourceDirectory, - bool Found, - IReadOnlyCollection Children, DetectionRule Rule -) - : FileReference(TableOfContentsScope, RelativePath, true, Children); diff --git a/src/Elastic.Documentation.Configuration/Products/Product.cs b/src/Elastic.Documentation.Configuration/Products/Product.cs index 94b84a602..185c230d0 100644 --- a/src/Elastic.Documentation.Configuration/Products/Product.cs +++ b/src/Elastic.Documentation.Configuration/Products/Product.cs @@ -24,6 +24,12 @@ public record ProductsConfiguration } } +[YamlSerializable] +public record ProductLink +{ + public string Id { get; set; } = string.Empty; +} + [YamlSerializable] public record Product { diff --git a/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs b/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs index b4920d65b..41987b1a3 100644 --- a/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs +++ b/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs @@ -4,6 +4,7 @@ using System.Collections.Frozen; using Elastic.Documentation.Configuration.Versions; +using YamlDotNet.Serialization; namespace Elastic.Documentation.Configuration.Products; @@ -36,11 +37,15 @@ public static ProductsConfiguration CreateProducts(this ConfigurationFileProvide internal sealed record ProductConfigDto { + [YamlMember(Alias = "products")] public Dictionary Products { get; set; } = []; } internal sealed record ProductDto { + [YamlMember(Alias = "display")] public string Display { get; set; } = string.Empty; + + [YamlMember(Alias = "versioning")] public string? Versioning { get; set; } public string? Repository { get; set; } } diff --git a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs index 92d930f24..825983ed0 100644 --- a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs +++ b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs @@ -6,6 +6,7 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Synonyms; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using YamlDotNet.Serialization; @@ -24,5 +25,10 @@ namespace Elastic.Documentation.Configuration.Serialization; [YamlSerializable(typeof(ProductDto))] [YamlSerializable(typeof(LegacyUrlMappingDto))] [YamlSerializable(typeof(LegacyUrlMappingConfigDto))] +[YamlSerializable(typeof(DocumentationSetFile))] +[YamlSerializable(typeof(TableOfContentsFile))] +[YamlSerializable(typeof(SiteNavigationFile))] +[YamlSerializable(typeof(PhantomRegistration))] +[YamlSerializable(typeof(ProductLink))] [YamlSerializable(typeof(SynonymsConfigDto))] public partial class YamlStaticContext; diff --git a/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs b/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs deleted file mode 100644 index 8303dace5..000000000 --- a/src/Elastic.Documentation.Configuration/TableOfContents/ITocItem.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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 Elastic.Documentation.Navigation; - -namespace Elastic.Documentation.Configuration.TableOfContents; - -public interface ITocItem -{ - ITableOfContentsScope TableOfContentsScope { get; } -} - -public record FileReference(ITableOfContentsScope TableOfContentsScope, string RelativePath, bool Hidden, IReadOnlyCollection Children) - : ITocItem; - -public record CrossLinkReference(ITableOfContentsScope TableOfContentsScope, Uri CrossLinkUri, string? Title, bool Hidden, IReadOnlyCollection Children) - : ITocItem; - -public record FolderReference(ITableOfContentsScope TableOfContentsScope, string RelativePath, IReadOnlyCollection Children) - : ITocItem; - -public record TocReference(Uri Source, ITableOfContentsScope TableOfContentsScope, string RelativePath, IReadOnlyCollection Children) - : FolderReference(TableOfContentsScope, RelativePath, Children) -{ - public IReadOnlyDictionary TocReferences { get; } = - Children.OfType().ToDictionary(kv => kv.Source, kv => kv); - - /// - /// A phantom table of contents is a table of contents that is not rendered in the UI but is used to generate the TOC. - /// This should be used sparingly and needs explicit configuration in navigation.yml. - /// It's typically used for container TOC that holds various other TOC's where its children are rehomed throughout the navigation. - /// Examples of phantom toc's: - /// - /// - toc: elasticsearch://reference - /// - toc: docs-content:// - /// - /// Because navigation.yml does exhaustive checks to ensure all toc.yml files are referenced, marking these containers as phantoms - /// ensures that these skip validation checks - /// - /// - public bool IsPhantom { get; init; } -} - diff --git a/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs b/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs new file mode 100644 index 000000000..f2fb61faf --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs @@ -0,0 +1,63 @@ +// 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; + +namespace Elastic.Documentation.Configuration.Toc.DetectionRules; + +public record DetectionRuleOverviewRef : FileRef +{ + public IReadOnlyCollection DetectionRuleFolders { get; } + + public DetectionRuleOverviewRef( + string pathRelativeToDocumentationSet, + string pathRelativeToContainer, + IReadOnlyCollection detectionRulesFolders, + IReadOnlyCollection children, + string context + ) : base(pathRelativeToDocumentationSet, pathRelativeToContainer, false, children, context) + { + PathRelativeToDocumentationSet = pathRelativeToDocumentationSet; + PathRelativeToContainer = pathRelativeToContainer; + DetectionRuleFolders = detectionRulesFolders; + Children = children; + Context = context; + } + + public static IReadOnlyCollection CreateTableOfContentItems(IReadOnlyCollection sourceFolders, string context, IDirectoryInfo baseDirectory) + { + var tocItems = new List(); + foreach (var detectionRuleFolder in sourceFolders) + { + var children = ReadDetectionRuleFolder(detectionRuleFolder, context, baseDirectory); + tocItems.AddRange(children); + } + + return tocItems + .ToArray(); + } + + private static IReadOnlyCollection ReadDetectionRuleFolder(IDirectoryInfo directory, string context, IDirectoryInfo baseDirectory) + { + IReadOnlyCollection children = directory + .EnumerateFiles("*.*", SearchOption.AllDirectories) + .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden) && !f.Attributes.HasFlag(FileAttributes.System)) + .Where(f => !f.Directory!.Attributes.HasFlag(FileAttributes.Hidden) && !f.Directory!.Attributes.HasFlag(FileAttributes.System)) + .Where(f => f.Extension is ".md" or ".toml") + .Where(f => f.Name != "README.md") + .Where(f => !f.FullName.Contains($"{Path.DirectorySeparatorChar}_deprecated{Path.DirectorySeparatorChar}")) + .Select(f => + { + // baseDirectory is 'docs' rules live relative to docs parent '/' + var relativePath = Path.GetRelativePath(baseDirectory.Parent!.FullName, f.FullName); + if (f.Extension == ".toml") + return new DetectionRuleRef(f, relativePath, context); + + return new FileRef(relativePath, relativePath, false, [], context); + }) + .ToArray(); + + return children; + } +} diff --git a/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleRef.cs b/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleRef.cs new file mode 100644 index 000000000..aef2c4a8e --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleRef.cs @@ -0,0 +1,10 @@ +// 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; + +namespace Elastic.Documentation.Configuration.Toc.DetectionRules; + +public record DetectionRuleRef(IFileInfo FileInfo, string PathRelativeToDocumentationSet, string Context) + : FileRef(PathRelativeToDocumentationSet, PathRelativeToDocumentationSet, true, [], Context); diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs new file mode 100644 index 000000000..ec9ab81cb --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -0,0 +1,540 @@ +// 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; +using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.Toc.DetectionRules; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Extensions; +using YamlDotNet.Serialization; + +namespace Elastic.Documentation.Configuration.Toc; + +[YamlSerializable] +public class DocumentationSetFile : TableOfContentsFile +{ + [YamlMember(Alias = "max_toc_depth")] + public int MaxTocDepth { get; set; } = 2; + + [YamlMember(Alias = "dev_docs")] + public bool DevDocs { get; set; } + + [YamlMember(Alias = "cross_links")] + public List CrossLinks { get; set; } = []; + + [YamlMember(Alias = "exclude")] + public List Exclude { get; set; } = []; + + [YamlMember(Alias = "extensions")] + public List Extensions { get; set; } = []; + + [YamlMember(Alias = "subs")] + public Dictionary Subs { get; set; } = []; + + [YamlMember(Alias = "features")] + public DocumentationSetFeatures Features { get; set; } = new(); + + [YamlMember(Alias = "api")] + public Dictionary Api { get; set; } = []; + + // TODO remove this + [YamlMember(Alias = "products")] + public List Products { get; set; } = []; + + public static FileRef[] GetFileRefs(ITableOfContentsItem item) + { + if (item is FileRef fileRef) + return [fileRef]; + if (item is FolderRef folderRef) + return folderRef.Children.SelectMany(GetFileRefs).ToArray(); + if (item is IsolatedTableOfContentsRef tocRef) + return tocRef.Children.SelectMany(GetFileRefs).ToArray(); + if (item is CrossLinkRef) + return []; + throw new Exception($"Unexpected item type {item.GetType().Name}"); + } + + private static new DocumentationSetFile Deserialize(string json) => + ConfigurationFileProvider.Deserializer.Deserialize(json); + + /// + /// Loads a DocumentationSetFile and recursively resolves all IsolatedTableOfContentsRef items, + /// replacing them with their resolved children and ensuring file paths carry over parent paths. + /// Validates the table of contents structure and emits diagnostics for issues. + /// + public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, IFileInfo docsetPath, IFileSystem? fileSystem = null) + { + fileSystem ??= docsetPath.FileSystem; + var yaml = fileSystem.File.ReadAllText(docsetPath.FullName); + var sourceDirectory = docsetPath.Directory!; + return LoadAndResolve(collector, yaml, sourceDirectory, fileSystem); + } + + /// + /// Loads a DocumentationSetFile from YAML string and recursively resolves all IsolatedTableOfContentsRef items, + /// replacing them with their resolved children and ensuring file paths carry over parent paths. + /// Validates the table of contents structure and emits diagnostics for issues. + /// + public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, string yaml, IDirectoryInfo sourceDirectory, IFileSystem? fileSystem = null) + { + fileSystem ??= sourceDirectory.FileSystem; + var docSet = Deserialize(yaml); + var docsetPath = fileSystem.Path.Combine(sourceDirectory.FullName, "docset.yml").OptionalWindowsReplace(); + docSet.TableOfContents = ResolveTableOfContents(collector, docSet.TableOfContents, sourceDirectory, fileSystem, parentPath: "", containerPath: "", context: docsetPath, docSet.SuppressDiagnostics); + return docSet; + } + + + /// + /// Recursively resolves all IsolatedTableOfContentsRef items in a table of contents, + /// loading nested TOC files and prepending parent paths to all file references. + /// Preserves the hierarchy structure without flattening. + /// Validates items and emits diagnostics for issues. + /// + private static TableOfContents ResolveTableOfContents( + IDiagnosticsCollector collector, + IReadOnlyCollection items, + IDirectoryInfo baseDirectory, + IFileSystem fileSystem, + string parentPath, + string containerPath, + string context, + HashSet? suppressDiagnostics = null + ) + { + var resolved = new TableOfContents(); + + foreach (var item in items) + { + var resolvedItem = item switch + { + IsolatedTableOfContentsRef tocRef => ResolveIsolatedToc(collector, tocRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), + DetectionRuleOverviewRef ruleOverviewReference => ResolveRuleOverviewReference(collector, ruleOverviewReference, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), + FileRef fileRef => ResolveFileRef(collector, fileRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), + FolderRef folderRef => ResolveFolderRef(collector, folderRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), + CrossLinkRef crossLink => ResolveCrossLinkRef(collector, crossLink, baseDirectory, fileSystem, parentPath, containerPath, context), + _ => null + }; + + if (resolvedItem != null) + resolved.Add(resolvedItem); + } + + return resolved; + } + + /// + /// Resolves an IsolatedTableOfContentsRef by loading the TOC file and returning a new ref with resolved children. + /// Validates that the TOC has no children in parent YAML and that toc.yml exists. + /// The TOC's path is set to the full path (including parent path) for consistency with files and folders. + /// +#pragma warning disable IDE0060 // Remove unused parameter - suppressDiagnostics is for consistency, nested TOCs use their own suppression config + private static ITableOfContentsItem? ResolveIsolatedToc(IDiagnosticsCollector collector, + IsolatedTableOfContentsRef tocRef, + IDirectoryInfo baseDirectory, + IFileSystem fileSystem, + string parentPath, + string containerPath, + string parentContext, + HashSet? suppressDiagnostics = null + ) +#pragma warning restore IDE0060 + { + // TOC paths containing '/' are treated as relative to the context file's directory (full paths). + // Simple TOC names (no '/') are resolved relative to the parent path in the navigation hierarchy. + string fullTocPath; + if (tocRef.PathRelativeToDocumentationSet.Contains('/')) + { + // Path contains '/', treat as context-relative (full path from the context file's directory) + var contextDir = fileSystem.Path.GetDirectoryName(parentContext) ?? ""; + var contextRelativePath = fileSystem.Path.GetRelativePath(baseDirectory.FullName, contextDir); + if (contextRelativePath == ".") + contextRelativePath = ""; + + fullTocPath = string.IsNullOrEmpty(contextRelativePath) + ? tocRef.PathRelativeToDocumentationSet + : $"{contextRelativePath}/{tocRef.PathRelativeToDocumentationSet}"; + } + else + { + // Simple name, resolve relative to parent path + fullTocPath = string.IsNullOrEmpty(parentPath) ? tocRef.PathRelativeToDocumentationSet : $"{parentPath}/{tocRef.PathRelativeToDocumentationSet}"; + } + + var tocDirectory = fileSystem.DirectoryInfo.New(fileSystem.Path.Combine(baseDirectory.FullName, fullTocPath)); + var tocFilePath = fileSystem.Path.Combine(tocDirectory.FullName, "toc.yml"); + var tocYmlExists = fileSystem.File.Exists(tocFilePath); + + // Validate: TOC should not have children defined in parent YAML + if (tocRef.Children.Count > 0) + { + collector.EmitError(parentContext, + $"TableOfContents '{fullTocPath}' may not contain children, define children in '{fullTocPath}/toc.yml' instead."); + return null; + } + + // PathRelativeToContainer for a TOC is the path relative to its parent container + var tocPathRelativeToContainer = string.IsNullOrEmpty(containerPath) + ? fullTocPath + : fullTocPath.Substring(containerPath.Length + 1); + + // If TOC has children in parent YAML, still try to load from toc.yml (prefer toc.yml over parent YAML) + if (!tocYmlExists) + { + // Validate: toc.yml file must exist + collector.EmitError(parentContext, $"Table of contents file not found: {fullTocPath}/toc.yml"); + return new IsolatedTableOfContentsRef(fullTocPath, tocPathRelativeToContainer, [], parentContext); + } + + var tocYaml = fileSystem.File.ReadAllText(tocFilePath); + var nestedTocFile = TableOfContentsFile.Deserialize(tocYaml); + + // this is temporary after this lands in main we can update these files to include + // suppress: + // - DeepLinkingVirtualFile + string[] skip = [ + "docs-content/solutions/toc.yml", + "docs-content/manage-data/toc.yml", + "docs-content/explore-analyze/toc.yml", + "docs-content/deploy-manage/toc.yml", + "docs-content/troubleshoot/toc.yml", + "docs-content/troubleshoot/ingest/opentelemetry/toc.yml", + "docs-content/reference/security/toc.yml" + ]; + + var path = tocFilePath.OptionalWindowsReplace(); + // Hardcode suppression for known problematic files + if (skip.Any(f => path.Contains(f, StringComparison.OrdinalIgnoreCase))) + _ = nestedTocFile.SuppressDiagnostics.Add(HintType.DeepLinkingVirtualFile); + + + // Recursively resolve children with the FULL TOC path as the parent path + // This ensures all file paths within the TOC include the TOC directory path + // The context for children is the toc.yml file that defines them + // For children of this TOC, the container path is fullTocPath (they're defined in toc.yml at that location) + var resolvedChildren = ResolveTableOfContents(collector, nestedTocFile.TableOfContents, baseDirectory, fileSystem, fullTocPath, fullTocPath, tocFilePath, nestedTocFile.SuppressDiagnostics); + + // Validate: TOC must have at least one child + if (resolvedChildren.Count == 0) + collector.EmitError(tocFilePath, $"Table of contents '{fullTocPath}' has no children defined"); + + // Return TOC ref with FULL path and resolved children + // The context remains the parent context (where this TOC was referenced) + return new IsolatedTableOfContentsRef(fullTocPath, tocPathRelativeToContainer, resolvedChildren, parentContext); + } + + /// + /// Resolves a FileRef by prepending the parent path to the file path and recursively resolving children. + /// The parent path provides the correct context for child resolution. + /// + private static ITableOfContentsItem ResolveFileRef(IDiagnosticsCollector collector, + FileRef fileRef, + IDirectoryInfo baseDirectory, + IFileSystem fileSystem, + string parentPath, + string containerPath, + string context, + HashSet? suppressDiagnostics = null) + { + var fullPath = string.IsNullOrEmpty(parentPath) ? fileRef.PathRelativeToDocumentationSet : $"{parentPath}/{fileRef.PathRelativeToDocumentationSet}"; + + // Special validation for FolderIndexFileRef (folder+file combination) + // Validate BEFORE early return so we catch cases with no children + if (fileRef is FolderIndexFileRef) + { + var fileName = fileRef.PathRelativeToDocumentationSet; + var fileWithoutExtension = fileName.Replace(".md", ""); + + // Validate: deep linking is NOT supported for folder+file combination + // The file path should be simple (no '/'), or at most folder/file.md after prepending + if (fileName.Contains('/')) + { + collector.EmitError(context, + $"Deep linking on folder 'file' is not supported. Found file path '{fileName}' with '/'. Use simple file name only."); + } + + // Best practice: file name should match folder name (from parentPath) + // Only check if we're in a folder context (parentPath is not empty) + if (!string.IsNullOrEmpty(parentPath) && fileName != "index.md") + { + // Check if this hint type should be suppressed + if (!suppressDiagnostics.ShouldSuppress(HintType.FolderFileNameMismatch)) + { + // Extract just the folder name from parentPath (in case it's nested like "guides/getting-started") + var folderName = parentPath.Contains('/') ? parentPath.Split('/')[^1] : parentPath; + + // Normalize for comparison: remove hyphens, underscores, and lowercase + // This allows "getting-started" to match "GettingStarted" or "getting_started" + var normalizedFile = fileWithoutExtension.Replace("-", "", StringComparison.Ordinal).Replace("_", "", StringComparison.Ordinal).ToLowerInvariant(); + var normalizedFolder = folderName.Replace("-", "", StringComparison.Ordinal).Replace("_", "", StringComparison.Ordinal).ToLowerInvariant(); + + if (!normalizedFile.Equals(normalizedFolder, StringComparison.Ordinal)) + { + collector.EmitHint(context, + $"File name '{fileName}' does not match folder name '{folderName}'. Best practice is to name the file the same as the folder (e.g., 'folder: {folderName}, file: {folderName}.md')."); + } + } + } + } + + // Calculate PathRelativeToContainer: the file path relative to its container + var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) + ? fullPath + : fullPath.Substring(containerPath.Length + 1); + + if (fileRef.Children.Count == 0) + { + // Preserve specific types even when there are no children + return fileRef switch + { + FolderIndexFileRef => new FolderIndexFileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, [], context), + IndexFileRef => new IndexFileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, [], context), + _ => new FileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, [], context) + }; + } + + // Emit hint if file has children and uses deep-linking (path contains '/') + // This suggests using 'folder' instead of 'file' would be better + if (fileRef.PathRelativeToDocumentationSet.Contains('/') && fileRef.Children.Count > 0 && fileRef is not FolderIndexFileRef) + { + // Check if this hint type should be suppressed + if (!suppressDiagnostics.ShouldSuppress(HintType.DeepLinkingVirtualFile)) + { + collector.EmitHint(context, + $"File '{fileRef.PathRelativeToDocumentationSet}' uses deep-linking with children. Consider using 'folder' instead of 'file' for better navigation structure. Virtual files are primarily intended to group sibling files together."); + } + } + + // Children of a file should be resolved in the same directory as the parent file. + // Special handling for FolderIndexFileRef (folder+file combinations from YAML): + // - These are created when both folder and file keys exist (e.g., "folder: path/to/dir, file: index.md") + // - Children should resolve to the folder path, not the parent TOC path + // Examples: + // - Top level: "nest/guide.md" (parentPath="") → children resolve to "nest/" + // - Simple file in folder: "guide.md" (parentPath="guides") → children resolve to "guides/" + // - User file with subpath: "clients/getting-started.md" (parentPath="guides") → children resolve to "guides/" + // - Folder+file (FolderIndexFileRef): "observability/apm/apm-server/index.md" → children resolve to directory of fullPath + string parentPathForChildren; + if (fileRef is FolderIndexFileRef) + { + // Folder+file combination - extract directory from fullPath + var lastSlashIndex = fullPath.LastIndexOf('/'); + parentPathForChildren = lastSlashIndex >= 0 ? fullPath[..lastSlashIndex] : ""; + } + else if (string.IsNullOrEmpty(parentPath)) + { + // Top level - extract directory from file path + var lastSlashIndex = fullPath.LastIndexOf('/'); + parentPathForChildren = lastSlashIndex >= 0 ? fullPath[..lastSlashIndex] : ""; + } + else + { + // In folder/TOC context - use parentPath directly, ignoring any subdirectory in the file reference + parentPathForChildren = parentPath; + } + + // For children of files, the container is still the current context (same container as the file itself) + var resolvedChildren = ResolveTableOfContents(collector, fileRef.Children, baseDirectory, fileSystem, parentPathForChildren, containerPath, context, suppressDiagnostics); + + // Preserve the specific type when creating the resolved reference + return fileRef switch + { + FolderIndexFileRef => new FolderIndexFileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, resolvedChildren, context), + IndexFileRef => new IndexFileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, resolvedChildren, context), + _ => new FileRef(fullPath, pathRelativeToContainer, fileRef.Hidden, resolvedChildren, context) + }; + } + + /// + /// Resolves a FolderRef by prepending the parent path to the folder path and recursively resolving children. + /// If no children are defined, auto-discovers .md files in the folder directory. + /// + private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCollector collector, + DetectionRuleOverviewRef detectionRuleRef, + IDirectoryInfo baseDirectory, + IFileSystem fileSystem, + string parentPath, + string containerPath, + string context, + HashSet? suppressDiagnostics = null) + { + // Folder paths containing '/' are treated as relative to the context file's directory (full paths). + // Simple folder names (no '/') are resolved relative to the parent path in the navigation hierarchy. + string fullPath; + if (detectionRuleRef.PathRelativeToDocumentationSet.Contains('/')) + { + // Path contains '/', treat as context-relative (full path from the context file's directory) + var contextDir = fileSystem.Path.GetDirectoryName(context) ?? ""; + var contextRelativePath = fileSystem.Path.GetRelativePath(baseDirectory.FullName, contextDir); + if (contextRelativePath == ".") + contextRelativePath = ""; + + fullPath = string.IsNullOrEmpty(contextRelativePath) + ? detectionRuleRef.PathRelativeToDocumentationSet + : $"{contextRelativePath}/{detectionRuleRef.PathRelativeToDocumentationSet}"; + } + else + { + // Simple name, resolve relative to parent path + fullPath = string.IsNullOrEmpty(parentPath) ? detectionRuleRef.PathRelativeToDocumentationSet : $"{parentPath}/{detectionRuleRef.PathRelativeToDocumentationSet}"; + } + + // Calculate PathRelativeToContainer: the folder path relative to its container + var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) + ? fullPath + : fullPath.Substring(containerPath.Length + 1); + + // For children of folders, the container remains the same as the folder's container + var resolvedChildren = ResolveTableOfContents(collector, detectionRuleRef.Children, baseDirectory, fileSystem, fullPath, containerPath, context, suppressDiagnostics); + + var fileInfo = fileSystem.NewFileInfo(baseDirectory.FullName, fullPath); + var tocSourceFolders = detectionRuleRef.DetectionRuleFolders + .Select(f => fileSystem.NewDirInfo(fileInfo.Directory!.FullName, f)) + .ToList(); + var tomlChildren = DetectionRuleOverviewRef.CreateTableOfContentItems(tocSourceFolders, context, baseDirectory); + + var children = resolvedChildren.Concat(tomlChildren).ToList(); + + return new DetectionRuleOverviewRef(fullPath, pathRelativeToContainer, detectionRuleRef.DetectionRuleFolders, children, context); + } + + + /// + /// Resolves a FolderRef by prepending the parent path to the folder path and recursively resolving children. + /// If no children are defined, auto-discovers .md files in the folder directory. + /// + private static ITableOfContentsItem ResolveFolderRef(IDiagnosticsCollector collector, + FolderRef folderRef, + IDirectoryInfo baseDirectory, + IFileSystem fileSystem, + string parentPath, + string containerPath, + string context, + HashSet? suppressDiagnostics = null) + { + // Folder paths containing '/' are treated as relative to the context file's directory (full paths). + // Simple folder names (no '/') are resolved relative to the parent path in the navigation hierarchy. + string fullPath; + if (folderRef.PathRelativeToDocumentationSet.Contains('/')) + { + // Path contains '/', treat as context-relative (full path from the context file's directory) + var contextDir = fileSystem.Path.GetDirectoryName(context) ?? ""; + var contextRelativePath = fileSystem.Path.GetRelativePath(baseDirectory.FullName, contextDir); + if (contextRelativePath == ".") + contextRelativePath = ""; + + fullPath = string.IsNullOrEmpty(contextRelativePath) + ? folderRef.PathRelativeToDocumentationSet + : $"{contextRelativePath}/{folderRef.PathRelativeToDocumentationSet}"; + } + else + { + // Simple name, resolve relative to parent path + fullPath = string.IsNullOrEmpty(parentPath) ? folderRef.PathRelativeToDocumentationSet : $"{parentPath}/{folderRef.PathRelativeToDocumentationSet}"; + } + + // Calculate PathRelativeToContainer: the folder path relative to its container + var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) + ? fullPath + : fullPath.Substring(containerPath.Length + 1); + + // If children are explicitly defined, resolve them + if (folderRef.Children.Count > 0) + { + // For children of folders, the container remains the same as the folder's container + var resolvedChildren = ResolveTableOfContents(collector, folderRef.Children, baseDirectory, fileSystem, fullPath, containerPath, context, suppressDiagnostics); + return new FolderRef(fullPath, pathRelativeToContainer, resolvedChildren, context); + } + + // No children defined - auto-discover .md files in the folder + var autoDiscoveredChildren = AutoDiscoverFolderFiles(collector, fullPath, containerPath, baseDirectory, fileSystem, context); + return new FolderRef(fullPath, pathRelativeToContainer, autoDiscoveredChildren, context); + } + + /// + /// Auto-discovers .md files in a folder directory and creates FileRef items for them. + /// If index.md exists, it's placed first. Otherwise, files are sorted alphabetically. + /// Files starting with '_' or '.' are excluded. + /// + private static TableOfContents AutoDiscoverFolderFiles( + IDiagnosticsCollector collector, + string folderPath, + string containerPath, + IDirectoryInfo baseDirectory, + IFileSystem fileSystem, + string context) + { + var directoryPath = fileSystem.Path.Combine(baseDirectory.FullName, folderPath); + var directory = fileSystem.DirectoryInfo.New(directoryPath); + + if (!directory.Exists) + return []; + + // Find all .md files in the directory (not recursive) + var mdFiles = fileSystem.Directory + .GetFiles(directoryPath, "*.md") + .Select(f => fileSystem.FileInfo.New(f)) + .Where(f => !f.Name.StartsWith('_') && !f.Name.StartsWith('.')) + .OrderBy(f => f.Name) + .ToList(); + + if (mdFiles.Count == 0) + return []; + + // Separate index.md from other files + var indexFile = mdFiles.FirstOrDefault(f => f.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase)); + var otherFiles = mdFiles.Where(f => !f.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase)).ToList(); + + var children = new TableOfContents(); + + // Add index.md first if it exists + if (indexFile != null) + { + var indexRef = indexFile.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase) + ? new IndexFileRef(indexFile.Name, indexFile.Name, false, [], context) + : new FileRef(indexFile.Name, indexFile.Name, false, [], context); + children.Add(indexRef); + } + + // Add other files sorted alphabetically + foreach (var file in otherFiles) + { + var fileRef = new FileRef(file.Name, file.Name, false, [], context); + children.Add(fileRef); + } + + // Resolve the children with the folder path as parent to get correct full paths + // Auto-discovered items are in the same container as the folder + return ResolveTableOfContents(collector, children, baseDirectory, fileSystem, folderPath, containerPath, context); + } + + /// + /// Resolves a CrossLinkRef by recursively resolving children (though cross-links typically don't have children). + /// + private static ITableOfContentsItem ResolveCrossLinkRef(IDiagnosticsCollector collector, + CrossLinkRef crossLinkRef, + IDirectoryInfo baseDirectory, + IFileSystem fileSystem, + string parentPath, + string containerPath, + string context) + { + if (crossLinkRef.Children.Count == 0) + return new CrossLinkRef(crossLinkRef.CrossLinkUri, crossLinkRef.Title, crossLinkRef.Hidden, [], context); + + // For children of cross-links, the container remains the same + var resolvedChildren = ResolveTableOfContents(collector, crossLinkRef.Children, baseDirectory, fileSystem, parentPath, containerPath, context); + + return new CrossLinkRef(crossLinkRef.CrossLinkUri, crossLinkRef.Title, crossLinkRef.Hidden, resolvedChildren, context); + } +} + +[YamlSerializable] +public class DocumentationSetFeatures +{ + [YamlMember(Alias = "primary-nav", ApplyNamingConventions = false)] + public bool? PrimaryNav { get; set; } + [YamlMember(Alias = "disable-github-edit-link", ApplyNamingConventions = false)] + public bool? DisableGithubEditLink { get; set; } +} diff --git a/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs b/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs new file mode 100644 index 000000000..409ac897b --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs @@ -0,0 +1,248 @@ +// 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.Collections.Immutable; +using System.IO.Abstractions; +using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Diagnostics; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace Elastic.Documentation.Configuration.Toc; + +public record NavigationTocMapping +{ + public required Uri Source { get; init; } + public required string SourcePathPrefix { get; init; } +} + +[YamlSerializable] +public class SiteNavigationFile +{ + [YamlMember(Alias = "phantoms")] + public IReadOnlyCollection Phantoms { get; set; } = []; + + [YamlMember(Alias = "toc")] + public SiteTableOfContents TableOfContents { get; set; } = []; + + public static SiteNavigationFile Deserialize(string yaml) => + ConfigurationFileProvider.Deserializer.Deserialize(yaml); + + public static bool ValidatePathPrefixes(IDiagnosticsCollector collector, SiteNavigationFile siteNavigation, IFileInfo navigationFile) + { + var sourcePathPrefixes = GetAllPathPrefixes(siteNavigation); + var pathPrefixSet = new HashSet(); + var valid = true; + + foreach (var pathPrefix in sourcePathPrefixes) + { + var prefix = $"{pathPrefix.Host}/{pathPrefix.AbsolutePath.Trim('/')}/"; + if (pathPrefixSet.Add(prefix)) + continue; + + var duplicateOf = sourcePathPrefixes.First(p => p.Host == pathPrefix.Host && p.AbsolutePath == pathPrefix.AbsolutePath); + collector.EmitError(navigationFile, $"Duplicate path prefix: {pathPrefix} duplicate: {duplicateOf}"); + valid = false; + } + + return valid; + } + + public static ImmutableHashSet GetAllDeclaredSources(SiteNavigationFile siteNavigation) + { + var set = new HashSet(); + + foreach (var tocRef in siteNavigation.TableOfContents) + CollectSource(tocRef, set); + + return set.ToImmutableHashSet(); + } + private static void CollectSource(SiteTableOfContentsRef tocRef, HashSet set) + { + _ = set.Add(tocRef.Source); + // Recursively collect from children + foreach (var child in tocRef.Children) + CollectSource(child, set); + } + + private static ImmutableHashSet GetAllPathPrefixes(SiteNavigationFile siteNavigation) + { + var set = new HashSet(); + + foreach (var tocRef in siteNavigation.TableOfContents) + CollectPathPrefixes(tocRef, set); + + return set.ToImmutableHashSet(); + } + + private static void CollectPathPrefixes(SiteTableOfContentsRef tocRef, HashSet set) + { + // Add path prefix for this toc ref + if (!string.IsNullOrEmpty(tocRef.PathPrefix)) + { + var pathUri = new Uri($"{tocRef.Source.Scheme}://{tocRef.PathPrefix.TrimEnd('/')}/"); + _ = set.Add(pathUri); + } + + // Recursively collect from children + foreach (var child in tocRef.Children) + CollectPathPrefixes(child, set); + } + + public static ImmutableHashSet GetPhantomPrefixes(SiteNavigationFile siteNavigation) + { + var set = new HashSet(); + + foreach (var phantom in siteNavigation.Phantoms) + { + var source = phantom.Source; + if (!source.Contains("://")) + source = ContentSourceMoniker.CreateString(NarrativeRepository.RepositoryName, source); + + _ = set.Add(new Uri(source)); + } + + return set.ToImmutableHashSet(); + } +} + +public class PhantomRegistration +{ + [YamlMember(Alias = "toc")] + public string Source { get; set; } = null!; +} + +public class SiteTableOfContents : List; + +public record SiteTableOfContentsRef(Uri Source, string PathPrefix, IReadOnlyCollection Children) + : ITableOfContentsItem +{ + // For site-level TOC refs, the Path is the path prefix (where it will be mounted in the site) + public string PathRelativeToDocumentationSet => PathPrefix; + + // For site-level TOC refs, PathRelativeToContainer is the same as PathRelativeToDocumentationSet + // since they're all defined in the same navigation.yml file + public string PathRelativeToContainer => PathPrefix; + + // For site-level TOC refs, the Context is the navigation.yml file path + // This will be set during site navigation loading + public string Context { get; init; } = ""; +} + +public class SiteTableOfContentsCollectionYamlConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(SiteTableOfContents); + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + var collection = new SiteTableOfContents(); + + if (!parser.TryConsume(out _)) + return collection; + + while (!parser.TryConsume(out _)) + { + var item = rootDeserializer(typeof(SiteTableOfContentsRef)); + if (item is SiteTableOfContentsRef tocRef) + collection.Add(tocRef); + } + + return collection; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + serializer.Invoke(value, type); +} + +public class SiteTableOfContentsRefYamlConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(SiteTableOfContentsRef); + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + if (!parser.TryConsume(out _)) + return null; + + var dictionary = new Dictionary(); + + while (!parser.TryConsume(out _)) + { + var key = parser.Consume(); + + // Parse the value based on what type it is + object? value = null; + if (parser.Accept(out var scalarValue)) + { + value = scalarValue.Value; + _ = parser.MoveNext(); + } + else if (parser.Accept(out _)) + { + // This is a list - parse it manually for "children" + if (key.Value == "children") + { + // Parse the children list manually + var childrenList = new List(); + _ = parser.Consume(); + while (!parser.TryConsume(out _)) + { + var child = rootDeserializer(typeof(SiteTableOfContentsRef)); + if (child is SiteTableOfContentsRef childRef) + childrenList.Add(childRef); + } + value = childrenList; + } + else + { + // For other lists, just skip them + parser.SkipThisAndNestedEvents(); + } + } + else if (parser.Accept(out _)) + { + // This is a nested mapping - skip it + parser.SkipThisAndNestedEvents(); + } + + dictionary[key.Value] = value; + } + + var children = GetChildren(dictionary); + + // Check for toc reference - required + if (dictionary.TryGetValue("toc", out var tocPath) && tocPath is string sourceString) + { + // Convert string to Uri - if no scheme, prepend "docs-content://" + var uriString = sourceString.Contains("://") ? sourceString : $"docs-content://{sourceString}"; + + if (!Uri.TryCreate(uriString, UriKind.Absolute, out var source)) + throw new InvalidOperationException($"Invalid TOC source: '{sourceString}' could not be parsed as a URI"); + + var pathPrefix = dictionary.TryGetValue("path_prefix", out var pathValue) && pathValue is string path + ? path + : string.Empty; + + return new SiteTableOfContentsRef(source, pathPrefix, children); + } + + return null; + } + + private IReadOnlyCollection GetChildren(Dictionary dictionary) + { + if (!dictionary.TryGetValue("children", out var childrenObj)) + return []; + + // Children have already been deserialized as List + if (childrenObj is List tocRefs) + return tocRefs; + + return []; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + serializer.Invoke(value, type); +} + diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsFile.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsFile.cs new file mode 100644 index 000000000..faa7f5865 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsFile.cs @@ -0,0 +1,35 @@ +// 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 Elastic.Documentation.Diagnostics; +using YamlDotNet.Serialization; + +namespace Elastic.Documentation.Configuration.Toc; + +[YamlSerializable] +public class TableOfContentsFile +{ + [YamlMember(Alias = "project")] + public string? Project { get; set; } + + [YamlMember(Alias = "toc")] + public TableOfContents TableOfContents { get; set; } = []; + + /// + /// Set of diagnostic hint types to suppress. Deserialized directly from YAML list of strings. + /// Valid values: "DeepLinkingVirtualFile", "FolderFileNameMismatch" + /// + [YamlMember(Alias = "suppress")] + public HashSet SuppressDiagnostics { get; set; } = []; + + public static TableOfContentsFile Deserialize(string json) => + ConfigurationFileProvider.Deserializer.Deserialize(json); +} + +public class TableOfContents : List +{ + public TableOfContents() { } + + public TableOfContents(IEnumerable items) : base(items) { } +} diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs new file mode 100644 index 000000000..d339d6034 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs @@ -0,0 +1,63 @@ +// 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 + +namespace Elastic.Documentation.Configuration.Toc; + +/// +/// Represents an item in a table of contents (file, folder, or TOC reference). +/// +public interface ITableOfContentsItem +{ + /// + /// The full path of this item relative to the documentation source directory. + /// For files: includes .md extension (e.g., "guides/getting-started.md") + /// For folders: the folder path (e.g., "guides/advanced") + /// For TOCs: the path to the toc.yml directory (e.g., "development" or "guides/advanced") + /// + string PathRelativeToDocumentationSet { get; } + + /// + /// The full path of this item relative to the container docset.yml or toc.yml file. + /// For files: includes .md extension (e.g., "guides/getting-started.md") + /// For folders: the folder path (e.g., "guides/advanced") + /// For TOCs: the path to the toc.yml directory (e.g., "development" or "guides/advanced") + /// + string PathRelativeToContainer { get; } + + /// + /// The path to the YAML file (docset.yml or toc.yml) that defined this item. + /// This provides context for where the item was declared in the configuration. + /// + string Context { get; } +} + +public record FileRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, bool Hidden, IReadOnlyCollection Children, string Context) + : ITableOfContentsItem; + +public record IndexFileRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, bool Hidden, IReadOnlyCollection Children, string Context) + : FileRef(PathRelativeToDocumentationSet, PathRelativeToContainer, Hidden, Children, Context); + +/// +/// Represents a file reference created from a folder+file combination in YAML (e.g., "folder: path/to/dir, file: index.md"). +/// Children of this file should resolve relative to the folder path, not the parent TOC path. +/// +public record FolderIndexFileRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, bool Hidden, IReadOnlyCollection Children, string Context) + : IndexFileRef(PathRelativeToDocumentationSet, PathRelativeToContainer, Hidden, Children, Context); + +public record CrossLinkRef(Uri CrossLinkUri, string? Title, bool Hidden, IReadOnlyCollection Children, string Context) + : ITableOfContentsItem +{ + //TODO ensure we pass these to cross-links to + // CrossLinks don't have a file system path, so we use the CrossLinkUri as the Path + public string PathRelativeToDocumentationSet => CrossLinkUri.ToString(); + + // CrossLinks don't have a file system path, so we use the CrossLinkUri as the Path + public string PathRelativeToContainer => CrossLinkUri.ToString(); +} + +public record FolderRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, IReadOnlyCollection Children, string Context) + : ITableOfContentsItem; + +public record IsolatedTableOfContentsRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, IReadOnlyCollection Children, string Context) + : ITableOfContentsItem; diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs new file mode 100644 index 000000000..cd215aed8 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs @@ -0,0 +1,186 @@ +// 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 Elastic.Documentation.Configuration.Toc.DetectionRules; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace Elastic.Documentation.Configuration.Toc; + +public class TocItemCollectionYamlConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(TableOfContents); + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + var collection = new TableOfContents(); + + if (!parser.TryConsume(out _)) + return collection; + + while (!parser.TryConsume(out _)) + { + var item = rootDeserializer(typeof(ITableOfContentsItem)); + if (item is ITableOfContentsItem tocItem) + collection.Add(tocItem); + } + + return collection; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + serializer.Invoke(value, type); +} + +public class TocItemYamlConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(ITableOfContentsItem); + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + if (!parser.TryConsume(out _)) + return null; + + var dictionary = new Dictionary(); + + while (!parser.TryConsume(out _)) + { + var key = parser.Consume(); + + // Parse the value based on what type it is + object? value = null; + if (parser.Accept(out var scalarValue)) + { + value = scalarValue.Value; + _ = parser.MoveNext(); + } + else if (parser.Accept(out _)) + { + // This is a list - parse it manually for "children" + if (key.Value == "children") + { + // Parse the children list manually + var childrenList = new List(); + _ = parser.Consume(); + while (!parser.TryConsume(out _)) + { + var child = rootDeserializer(typeof(ITableOfContentsItem)); + if (child is ITableOfContentsItem tocItem) + childrenList.Add(tocItem); + } + value = childrenList; + } + else if (key.Value == "detection_rules") + { + // Parse the children list manually + var childrenList = new List(); + _ = parser.Consume(); + while (!parser.TryConsume(out _)) + { + if (parser.Accept(out scalarValue)) + childrenList.Add(scalarValue.Value); + _ = parser.MoveNext(); + } + value = childrenList.ToArray(); + } + else + { + // For other lists, just skip them + parser.SkipThisAndNestedEvents(); + } + } + else if (parser.Accept(out _)) + { + // This is a nested mapping - skip it + parser.SkipThisAndNestedEvents(); + } + + dictionary[key.Value] = value; + } + + var children = GetChildren(dictionary); + + // Context will be set during LoadAndResolve, use empty string as placeholder during deserialization + const string placeholderContext = ""; + + // Check for folder+file combination (e.g., folder: getting-started, file: getting-started.md) + // This represents a folder with a specific index file + // The file becomes a child of the folder (as FolderIndexFileRef), and user-specified children follow + if (dictionary.TryGetValue("folder", out var folderPath) && folderPath is string folder && + dictionary.TryGetValue("file", out var filePath) && filePath is string file) + { + // Create the index file reference (FolderIndexFileRef to mark it as the folder's index) + // Store ONLY the file name - the folder path will be prepended during resolution + // This allows validation to check if the file itself has deep paths + // PathRelativeToContainer will be set during resolution + var indexFile = new FolderIndexFileRef(file, file, false, [], placeholderContext); + + // Create a list with the index file first, followed by user-specified children + var folderChildren = new List { indexFile }; + folderChildren.AddRange(children); + + // Return a FolderRef with the index file and children + // The folder path can be deep (e.g., "guides/getting-started"), that's OK + // PathRelativeToContainer will be set during resolution + return new FolderRef(folder, folder, folderChildren, placeholderContext); + } + if (dictionary.TryGetValue("detection_rules", out var detectionRulesObj) && detectionRulesObj is string[] detectionRulesFolders && + dictionary.TryGetValue("file", out var detectionRulesFilePath) && detectionRulesFilePath is string detectionRulesFile) + { + // Create the index file reference (FolderIndexFileRef to mark it as the folder's index) + // Store ONLY the file name - the folder path will be prepended during resolution + // This allows validation to check if the file itself has deep paths + // PathRelativeToContainer will be set during resolution + return new DetectionRuleOverviewRef(detectionRulesFile, detectionRulesFile, detectionRulesFolders, children, placeholderContext); + } + + // Check for file reference (file: or hidden:) + // 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 (dictionary.TryGetValue("hidden", out var hiddenPath) && hiddenPath is string p) + return p == "index.md" ? new IndexFileRef(p, p, true, children, placeholderContext) : new FileRef(p, p, true, children, placeholderContext); + + // Check for crosslink reference + if (dictionary.TryGetValue("crosslink", out var crosslink) && crosslink is string crosslinkStr) + { + var title = dictionary.TryGetValue("title", out var t) && t is string titleStr ? titleStr : null; + var isHidden = dictionary.TryGetValue("hidden", out var h) && h is bool hiddenBool && hiddenBool; + return new CrossLinkRef(new Uri(crosslinkStr), title, isHidden, children, placeholderContext); + } + + // Check for folder reference + // PathRelativeToContainer will be set during resolution + if (dictionary.TryGetValue("folder", out var folderPathOnly) && folderPathOnly is string folderOnly) + return new FolderRef(folderOnly, folderOnly, children, placeholderContext); + + // Check for toc reference + // PathRelativeToContainer will be set during resolution + if (dictionary.TryGetValue("toc", out var tocPath) && tocPath is string source) + return new IsolatedTableOfContentsRef(source, source, children, placeholderContext); + + return null; + } + + private static IReadOnlyCollection GetChildren(Dictionary dictionary) + { + if (!dictionary.TryGetValue("children", out var childrenObj)) + return []; + + // Children have already been deserialized as List + if (childrenObj is List tocItems) + return tocItems; + + return []; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + serializer.Invoke(value, type); +} diff --git a/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs b/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs index 00ae91b88..290b88c75 100644 --- a/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs +++ b/src/Elastic.Documentation.Configuration/Versions/VersionsConfigurationExtensions.cs @@ -2,6 +2,8 @@ // 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 YamlDotNet.Serialization; + namespace Elastic.Documentation.Configuration.Versions; public static class VersionsConfigurationExtensions @@ -56,7 +58,9 @@ internal sealed record VersionsConfigDto internal sealed record VersioningSystemDto { + [YamlMember(Alias = "base")] public string Base { get; set; } = string.Empty; + [YamlMember(Alias = "current")] public string Current { get; set; } = string.Empty; } diff --git a/src/Elastic.Documentation.LegacyDocs/PageLegacyUrlMapper.cs b/src/Elastic.Documentation.LegacyDocs/PageLegacyUrlMapper.cs index 2553a86ab..d50fdce3e 100644 --- a/src/Elastic.Documentation.LegacyDocs/PageLegacyUrlMapper.cs +++ b/src/Elastic.Documentation.LegacyDocs/PageLegacyUrlMapper.cs @@ -29,9 +29,7 @@ public PageLegacyUrlMapper(LegacyPageService legacyPageService, VersionsConfigur var mappedPage = mappedPages.First(); if (LegacyUrlMappings.Mappings.FirstOrDefault(x => mappedPage.Contains(x.BaseUrl, StringComparison.OrdinalIgnoreCase)) is not { } legacyMappingMatch) - { return [new LegacyPageMapping(LegacyUrlMappings.Mappings.First(x => x.Product.Id.Equals("elastic-stack", StringComparison.OrdinalIgnoreCase)).Product, mappedPages.FirstOrDefault() ?? string.Empty, DefaultVersion, false)]; - } var allVersions = new List(); diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs index 0cbc34d9f..c2a3b32ac 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs @@ -39,11 +39,14 @@ public static RepositoryLinks Deserialize(string json) => public abstract Task FetchCrossLinks(Cancel ctx); + private int _logOnce; public async Task FetchLinkRegistry(Cancel ctx) { + var result = Interlocked.Increment(ref _logOnce); if (_linkIndex is not null) { - Logger.LogTrace("Using cached link index registry (link-index.json)"); + if (result == 1) + Logger.LogTrace("Using cached link index registry (link-index.json)"); return _linkIndex; } diff --git a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs new file mode 100644 index 000000000..4f9c0c7f6 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs @@ -0,0 +1,273 @@ +// 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.Collections.Immutable; +using System.Diagnostics; +using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Isolated.Node; + +namespace Elastic.Documentation.Navigation.Assembler; + +[DebuggerDisplay("{Url}")] +public class SiteNavigation : IRootNavigationItem +{ + private readonly string? _sitePrefix; + + public SiteNavigation( + SiteNavigationFile siteNavigationFile, + IDocumentationContext context, + IReadOnlyCollection documentationSetNavigations, + string? sitePrefix + ) + { + // Normalize sitePrefix to ensure it has a leading slash and no trailing slash + _sitePrefix = NormalizeSitePrefix(sitePrefix); + // Initialize root properties + NavigationRoot = this; + Parent = null; + Hidden = false; + Id = ShortId.Create("site"); + IsUsingNavigationDropdown = false; + Phantoms = siteNavigationFile.Phantoms; + DeclaredPhantoms = [.. siteNavigationFile.Phantoms.Select(p => new Uri(p.Source))]; + DeclaredTableOfContents = SiteNavigationFile.GetAllDeclaredSources(siteNavigationFile); + NavigationTitle = "Elastic Docs"; + + _nodes = []; + foreach (var setNavigation in documentationSetNavigations) + { + foreach (var (identifier, node) in setNavigation.TableOfContentNodes) + { + if (!_nodes.TryAdd(identifier, node)) + { + //TODO configurationFileProvider navigation path + context.EmitError(context.ConfigurationPath, $"Duplicate navigation identifier: {identifier} in navigation.yml"); + } + } + } + UnseenNodes = [.. _nodes.Keys]; + // Build NavigationItems from SiteTableOfContentsRef items + var items = new List(); + var index = 0; + foreach (var tocRef in siteNavigationFile.TableOfContents) + { + var navItem = CreateSiteTableOfContentsNavigation( + tocRef, + index++, + context, + this, + null + ); + + if (navItem != null) + items.Add(navItem); + } + + var indexNavigation = items.QueryIndex(this, "/index.md", out var navigationItems); + Index = indexNavigation; + NavigationItems = navigationItems; + _ = this.UpdateNavigationIndex(context); + foreach (var node in UnseenNodes) + { + // impossible since unseen nodes are build from _nodes + if (!_nodes.TryGetValue(node, out var value)) + continue; + if (!DeclaredPhantoms.Contains(node)) + context.EmitHint(context.ConfigurationPath, $"Navigation does not explicitly declare: {node} as a phantom"); + + // ensure the parent of phantom nodes is `SiteNavigation` + value.Parent = this; + } + + } + + public HashSet DeclaredPhantoms { get; } + + /// All the table of contents explicitly declared in the navigation + public ImmutableHashSet DeclaredTableOfContents { get; set; } + + private readonly Dictionary> _nodes; + public IReadOnlyDictionary> Nodes => _nodes; + + private HashSet UnseenNodes { get; } + + public IReadOnlyCollection Phantoms { get; } + + /// + public Uri Identifier { get; } = new Uri("site://"); + + //TODO Obsolete? + public IReadOnlyCollection> TopLevelItems => + NavigationItems.OfType>().ToList(); + + /// + public string Url => string.IsNullOrEmpty(_sitePrefix) ? "/" : _sitePrefix; + + /// + public string NavigationTitle { get; } + + /// + public IRootNavigationItem NavigationRoot { get; } + + /// + public INodeNavigationItem? Parent { get; set; } + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + /// + public string Id { get; } + + /// + public ILeafNavigationItem Index { get; } + + /// + public bool IsUsingNavigationDropdown { get; } + + /// + public IReadOnlyCollection NavigationItems { get; } + + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => + throw new NotSupportedException("SetNavigationItems is not supported on SiteNavigation"); + + /// + /// Normalizes the site prefix to ensure it has a leading slash and no trailing slash. + /// Returns null for null or empty/whitespace input. + /// + private static string? NormalizeSitePrefix(string? sitePrefix) + { + if (string.IsNullOrWhiteSpace(sitePrefix)) + return null; + + var normalized = sitePrefix.Trim(); + + // Ensure leading slash + if (!normalized.StartsWith('/')) + normalized = "/" + normalized; + + // Remove trailing slash + normalized = normalized.TrimEnd('/'); + + return normalized; + } + + private INavigationItem? CreateSiteTableOfContentsNavigation( + SiteTableOfContentsRef tocRef, + int index, + IDocumentationContext context, + INodeNavigationItem parent, + IRootNavigationItem? root + ) + { + var pathPrefix = tocRef.PathPrefix; + // Validate that path_prefix is set + if (string.IsNullOrWhiteSpace(pathPrefix)) + { + // we allow not setting path prefixes for toc references from the narrative repository + if (tocRef.Source.Scheme != NarrativeRepository.RepositoryName) + { + context.EmitError(context.ConfigurationPath, $"path_prefix is required for TOC reference: {tocRef.Source}"); + pathPrefix += $"bad-mapping-{tocRef.Source.Scheme}-{tocRef.Source.Host}-{tocRef.Source.AbsolutePath}".TrimEnd('/').TrimEnd('-'); + pathPrefix += "/"; + } + else + { + if (!string.IsNullOrEmpty(tocRef.Source.Host)) + pathPrefix += $"/{tocRef.Source.Host}"; + if (!string.IsNullOrEmpty(tocRef.Source.AbsolutePath) && tocRef.Source.AbsolutePath != "/") + pathPrefix += $"/{tocRef.Source.AbsolutePath}"; + } + } + + // Normalize pathPrefix to remove leading/trailing slashes for a consistent combination + pathPrefix = pathPrefix.Trim('/'); + + // Combine with site prefix if present, otherwise ensure leading slash + pathPrefix = !string.IsNullOrWhiteSpace(_sitePrefix) ? $"{_sitePrefix}/{pathPrefix}" : "/" + pathPrefix; + + // Look up the node in the collected nodes + if (!_nodes.TryGetValue(tocRef.Source, out var node)) + { + context.EmitError(context.ConfigurationPath, $"Could not find navigation node for identifier: {tocRef.Source} (from source: {tocRef.Source})"); + return null; + } + if (node is not INavigationHomeAccessor homeAccessor) + { + context.EmitError(context.ConfigurationPath, $"Navigation contains an node navigation that does not implement: {nameof(INavigationHomeAccessor)} (from source: {tocRef.Source})"); + return null; + } + + root ??= node; + + _ = UnseenNodes.Remove(tocRef.Source); + // Set the navigation index + node.Parent = parent; + node.NavigationIndex = index; + homeAccessor.HomeProvider = new NavigationHomeProvider(pathPrefix, root); + + var children = new List(); + + // Always start with the node's existing children and update their HomeProvider + INavigationItem[] nodeChildren = [node.Index, .. node.NavigationItems]; + foreach (var nodeChild in nodeChildren) + { + nodeChild.Parent = node; + if (nodeChild is INavigationHomeAccessor childAccessor) + childAccessor.HomeProvider = homeAccessor.HomeProvider; + + // roots are only added if configured by navigation.yml (tocRef) + if (nodeChild is IRootNavigationItem) + continue; + + children.Add(nodeChild); + } + + // If there are additional children defined in the site navigation, add those too + if (tocRef.Children.Count > 0) + { + var childIndex = 0; + foreach (var child in tocRef.Children) + { + var childItem = CreateSiteTableOfContentsNavigation( + child, + childIndex++, + context, + node, + root + ); + if (childItem != null) + children.Add(childItem); + } + } + + // Check for any undeclared nested TOCs in the node's children + INavigationItem[] allNodeChildren = [.. node.NavigationItems, node.Index]; + foreach (var nodeChild in allNodeChildren) + { + if (nodeChild is not IRootNavigationItem rootChild) + continue; + if (DeclaredTableOfContents.Contains(rootChild.Identifier) || DeclaredPhantoms.Contains(rootChild.Identifier)) + continue; + + context.EmitWarning(context.ConfigurationPath, $"Navigation does not explicitly declare: {rootChild.Identifier}"); + } + + switch (node) + { + case SiteNavigation: + break; + case IAssignableChildrenNavigation documentationSetNavigation: + documentationSetNavigation.SetNavigationItems(children); + break; + default: + throw new Exception($"node is not a known type: {node.GetType().Name}"); + } + return node; + } +} diff --git a/src/Elastic.Documentation.Navigation/Elastic.Documentation.Navigation.csproj b/src/Elastic.Documentation.Navigation/Elastic.Documentation.Navigation.csproj new file mode 100644 index 000000000..4a57206c2 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Elastic.Documentation.Navigation.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/src/Elastic.Documentation.Navigation/IDocumentationFile.cs b/src/Elastic.Documentation.Navigation/IDocumentationFile.cs new file mode 100644 index 000000000..42c6a48c0 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/IDocumentationFile.cs @@ -0,0 +1,13 @@ +// 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 + +namespace Elastic.Documentation.Navigation; + +/// Represents a documentation file that can be used in navigation. +/// Extends with a navigation title. +public interface IDocumentationFile : INavigationModel +{ + /// Gets the title to display in navigation for this documentation file. + string NavigationTitle { get; } +} diff --git a/src/Elastic.Documentation.Navigation/INavigationHomeProvider.cs b/src/Elastic.Documentation.Navigation/INavigationHomeProvider.cs new file mode 100644 index 000000000..1b4d78e51 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/INavigationHomeProvider.cs @@ -0,0 +1,34 @@ +// 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.Diagnostics; + +namespace Elastic.Documentation.Navigation; + +public interface INavigationHomeProvider +{ + string PathPrefix { get; } + IRootNavigationItem NavigationRoot { get; } + string Id { get; } +} + +public interface INavigationHomeAccessor +{ + INavigationHomeProvider HomeProvider { get; set; } +} + +[DebuggerDisplay("{PathPrefix} => {NavigationRoot.Url}")] +public class NavigationHomeProvider(string pathPrefix, IRootNavigationItem navigationRoot) : INavigationHomeProvider +{ + /// + public string PathPrefix { get; } = pathPrefix; + + /// + public IRootNavigationItem NavigationRoot { get; } = navigationRoot; + + public string Id { get; } = Guid.NewGuid().ToString("N"); + + public override string ToString() => $"{PathPrefix} => {NavigationRoot.Url}"; +} + diff --git a/src/Elastic.Documentation.Site/Navigation/INavigationItem.cs b/src/Elastic.Documentation.Navigation/INavigationItem.cs similarity index 81% rename from src/Elastic.Documentation.Site/Navigation/INavigationItem.cs rename to src/Elastic.Documentation.Navigation/INavigationItem.cs index 4ec006872..3a99e6f07 100644 --- a/src/Elastic.Documentation.Site/Navigation/INavigationItem.cs +++ b/src/Elastic.Documentation.Navigation/INavigationItem.cs @@ -2,7 +2,7 @@ // 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 -namespace Elastic.Documentation.Site.Navigation; +namespace Elastic.Documentation.Navigation; /// Represents navigation model data for documentation elements. public interface INavigationModel @@ -23,20 +23,12 @@ public interface INavigationItem /// Gets the root navigation item. IRootNavigationItem NavigationRoot { get; } - /// /// Gets or sets the parent navigation item. - /// - /// - /// TODO: This should be read-only however currently needs the setter in assembler. - /// INodeNavigationItem? Parent { get; set; } bool Hidden { get; } int NavigationIndex { get; set; } - - /// Gets whether this navigation item is a cross-link to another repository. - bool IsCrossLink { get; } } /// Represents a leaf node in the navigation tree with associated model data. @@ -56,24 +48,26 @@ public interface INodeNavigationItem : INaviga where TIndex : INavigationModel where TChildNavigation : INavigationItem { - /// Gets the depth level in the navigation hierarchy. - int Depth { get; } - /// Gets the unique identifier for this node. string Id { get; } /// Gets the index model associated with this node. - TIndex Index { get; } + ILeafNavigationItem Index { get; } - /// /// Gets the collection of child navigation items. - /// IReadOnlyCollection NavigationItems { get; } } -public interface IRootNavigationItem : INodeNavigationItem +public interface IAssignableChildrenNavigation +{ + void SetNavigationItems(IReadOnlyCollection navigationItems); +} + +public interface IRootNavigationItem : INodeNavigationItem, IAssignableChildrenNavigation where TIndex : INavigationModel where TChildNavigation : INavigationItem { bool IsUsingNavigationDropdown { get; } + + Uri Identifier { get; } } diff --git a/src/Elastic.Documentation.Navigation/Isolated/DocumentationNavigationFactory.cs b/src/Elastic.Documentation.Navigation/Isolated/DocumentationNavigationFactory.cs new file mode 100644 index 000000000..2a6e104b3 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/DocumentationNavigationFactory.cs @@ -0,0 +1,44 @@ +// 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; +using Elastic.Documentation.Navigation.Isolated.Leaf; +using Elastic.Documentation.Navigation.Isolated.Node; + +namespace Elastic.Documentation.Navigation.Isolated; + +/// +/// Factory interface for creating documentation file models from file system paths. +/// +/// The type of documentation file to create +public interface IDocumentationFileFactory where TModel : IDocumentationFile +{ + /// + /// Attempts to create a documentation file model from the given file path. + /// + /// The file path to create a model for + /// The file system to read from + /// A documentation file model, or null if creation failed + TModel? TryCreateDocumentationFile(IFileInfo path, IFileSystem readFileSystem); +} + +/// +/// Factory for creating navigation items from documentation files. +/// +public static class DocumentationNavigationFactory +{ + /// + /// Creates a file navigation leaf from a documentation file model. + /// + public static ILeafNavigationItem CreateFileNavigationLeaf(TModel model, IFileInfo fileInfo, FileNavigationArgs args) + where TModel : IDocumentationFile => + new FileNavigationLeaf(model, fileInfo, args) { NavigationIndex = args.NavigationIndex }; + + /// + /// Creates a virtual file navigation node from a documentation file model. + /// + public static VirtualFileNavigation CreateVirtualFileNavigation(TModel model, IFileInfo fileInfo, VirtualFileNavigationArgs args) + where TModel : IDocumentationFile => + new(model, fileInfo, args) { NavigationIndex = args.NavigationIndex }; +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/Leaf/CrossLinkNavigationLeaf.cs b/src/Elastic.Documentation.Navigation/Isolated/Leaf/CrossLinkNavigationLeaf.cs new file mode 100644 index 000000000..bca878c2b --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/Leaf/CrossLinkNavigationLeaf.cs @@ -0,0 +1,47 @@ +// 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.Diagnostics; + +namespace Elastic.Documentation.Navigation.Isolated.Leaf; + +/// +/// Represents a cross-link to an external documentation resource. +/// +/// The URI pointing to the external resource +/// The title to display in navigation +public record CrossLinkModel(Uri CrossLinkUri, string NavigationTitle) : IDocumentationFile; + +[DebuggerDisplay("{Url}")] +public class CrossLinkNavigationLeaf( + CrossLinkModel model, + string url, + bool hidden, + INodeNavigationItem? parent, + INavigationHomeAccessor homeAccessor +) + : ILeafNavigationItem +{ + /// + public CrossLinkModel Model { get; } = model; + + /// + public string Url { get; } = url; + + /// + public bool Hidden { get; } = hidden; + + /// + public IRootNavigationItem NavigationRoot => homeAccessor.HomeProvider.NavigationRoot; + + /// + public INodeNavigationItem? Parent { get; set; } = parent; + + /// + public string NavigationTitle => Model.NavigationTitle; + + /// + public int NavigationIndex { get; set; } + +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs b/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs new file mode 100644 index 000000000..55b17d3c3 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs @@ -0,0 +1,80 @@ +// 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.Diagnostics; +using System.IO.Abstractions; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Assembler; + +namespace Elastic.Documentation.Navigation.Isolated.Leaf; + +[DebuggerDisplay("{Url}")] +public class FileNavigationLeaf(TModel model, IFileInfo fileInfo, FileNavigationArgs args) : ILeafNavigationItem + where TModel : IDocumentationFile +{ + public IFileInfo FileInfo { get; } = fileInfo; + + /// + public TModel Model { get; } = model; + + private string? _homeProviderCache; + private string? _urlCache; + + /// + public string Url + { + get + { + if (_homeProviderCache is not null && _homeProviderCache == args.HomeAccessor.HomeProvider.Id && _urlCache is not null) + return _urlCache; + + + _homeProviderCache = args.HomeAccessor.HomeProvider.Id; + + _urlCache = DetermineUrl(); + return _urlCache; + + string DetermineUrl() + { + var rootUrl = args.HomeAccessor.HomeProvider.PathPrefix.TrimEnd('/'); + var relativeToContainer = args.HomeAccessor.HomeProvider.NavigationRoot.Parent is SiteNavigation; + + // Remove extension while preserving the directory path + var relativePath = relativeToContainer ? args.RelativePathToTableOfContents : args.RelativePathToDocumentationSet; + relativePath = relativePath.OptionalWindowsReplace(); + relativePath = Path.ChangeExtension(relativePath, "md"); + var path = relativePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? relativePath[..^3] // Remove last 3 characters (.md) + : relativePath; + + // If a path ends with /index or is just index, omit it from the URL + if (path.EndsWith("/index", StringComparison.OrdinalIgnoreCase)) + path = path[..^6]; // Remove "/index" + else if (path.Equals("index", StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrEmpty(rootUrl) ? "/" : $"{rootUrl}"; + + if (string.IsNullOrEmpty(path)) + return string.IsNullOrEmpty(rootUrl) ? "/" : $"{rootUrl}"; + + return $"{rootUrl}/{path.TrimEnd('/')}"; + } + } + } + + /// + public bool Hidden { get; } = args.Hidden; + + /// + public IRootNavigationItem NavigationRoot => args.HomeAccessor.HomeProvider.NavigationRoot; + + /// + public INodeNavigationItem? Parent { get; set; } = args.Parent; + + /// + public string NavigationTitle => Model.NavigationTitle; + + /// + public int NavigationIndex { get; set; } + +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/NavigationArguments.cs b/src/Elastic.Documentation.Navigation/Isolated/NavigationArguments.cs new file mode 100644 index 000000000..648daacb6 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/NavigationArguments.cs @@ -0,0 +1,41 @@ +// 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 + +namespace Elastic.Documentation.Navigation.Isolated; + +/// +/// Arguments for creating a file navigation leaf. +/// +/// The relative path from the documentation set root +/// The relative path from the table of contents root +/// Whether this navigation item should be hidden from navigation +/// The index position in navigation +/// The parent navigation item +/// The home accessor for this navigation item +public record FileNavigationArgs( + string RelativePathToDocumentationSet, + string RelativePathToTableOfContents, + bool Hidden, + int NavigationIndex, + INodeNavigationItem? Parent, + INavigationHomeAccessor HomeAccessor +); + +/// +/// Arguments for creating a virtual file navigation node. +/// +/// The relative path from the documentation set root +/// The relative path from the table of contents root +/// Whether this navigation item should be hidden from navigation +/// The index position in navigation +/// The parent navigation item +/// The home accessor for this navigation item +public record VirtualFileNavigationArgs( + string RelativePathToDocumentationSet, + string RelativePathToTableOfContents, + bool Hidden, + int NavigationIndex, + INodeNavigationItem? Parent, + INavigationHomeAccessor HomeAccessor +); diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs new file mode 100644 index 000000000..56ec4412b --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs @@ -0,0 +1,434 @@ +// 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.Diagnostics; +using System.IO.Abstractions; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Configuration.Toc.DetectionRules; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Links.CrossLinks; +using Elastic.Documentation.Navigation.Isolated.Leaf; + +namespace Elastic.Documentation.Navigation.Isolated.Node; + +public interface IDocumentationSetNavigation +{ + IReadOnlyDictionary> TableOfContentNodes { get; } +} + +[DebuggerDisplay("{Url}")] +public class DocumentationSetNavigation + : IDocumentationSetNavigation, IRootNavigationItem, INavigationHomeAccessor, INavigationHomeProvider + + where TModel : class, IDocumentationFile +{ + private readonly IDocumentationFileFactory _factory; + private readonly ICrossLinkResolver _crossLinkResolver; + + public DocumentationSetNavigation( + DocumentationSetFile documentationSet, + IDocumentationSetContext context, + IDocumentationFileFactory factory, + IRootNavigationItem? parent = null, + IRootNavigationItem? root = null, + string? pathPrefix = null, + ICrossLinkResolver? crossLinkResolver = null + ) + { + _context = context; + _factory = factory; + _crossLinkResolver = crossLinkResolver ?? NoopCrossLinkResolver.Instance; + _pathPrefix = pathPrefix ?? string.Empty; + // Initialize root properties + _navigationRoot = root ?? this; + Parent = parent; + Hidden = false; + HomeProvider = this; + Id = ShortId.Create(documentationSet.Project ?? "root"); + IsUsingNavigationDropdown = documentationSet.Features.PrimaryNav ?? false; + Git = context.Git; + Identifier = new Uri($"{Git.RepositoryName}://"); + _ = _tableOfContentNodes.TryAdd(Identifier, this); + + // Convert TOC items to navigation items + var items = new List(); + var index = -1; + foreach (var tocItem in documentationSet.TableOfContents) + { + var navItem = ConvertToNavigationItem( + tocItem, + index++, + context, + parent: this, + homeAccessor: this + ); + + if (navItem != null) + items.Add(navItem); + } + + // Handle empty TOC - emit errors and create a minimal structure + if (items.Count == 0) + { + var setName = documentationSet.Project ?? "unnamed"; + var setPath = context.ConfigurationPath.FullName; + + // Emit error if TOC was defined but no items could be created + if (documentationSet.TableOfContents.Count > 0) + context.EmitError(context.ConfigurationPath, $"Documentation set '{setName}' ({setPath}) table of contents has items defined but none could be created"); + // Emit error if TOC was never defined + else + context.EmitError(context.ConfigurationPath, $"Documentation set '{setName}' ({setPath}) has no table of contents defined"); + + Index = null!; + NavigationItems = []; + } + else + { + var indexNavigation = items.QueryIndex(this, $"{PathPrefix}/index.md", out var navigationItems); + Index = indexNavigation; + NavigationItems = navigationItems; + _ = this.UpdateNavigationIndex(context); + } + + } + + private readonly string _pathPrefix; + private readonly IRootNavigationItem _navigationRoot; + + /// + /// Gets the path prefix. When HomeProvider is set to a different instance, it returns that provider's prefix. + /// Otherwise, returns the prefix set during construction. + /// + public string PathPrefix => HomeProvider == this ? _pathPrefix : HomeProvider.PathPrefix; + + public INavigationHomeProvider HomeProvider { get; set; } + + public GitCheckoutInformation Git { get; } + + private readonly Dictionary> _tableOfContentNodes = []; + private readonly IDocumentationSetContext _context; + public IReadOnlyDictionary> TableOfContentNodes => + _tableOfContentNodes.ToDictionary(kvp => kvp.Key, IRootNavigationItem (kvp) => kvp.Value); + + public Uri Identifier { get; } + + /// + public string Url => Index.Url; + + /// + public string NavigationTitle => Index.NavigationTitle; + + public IRootNavigationItem NavigationRoot => + HomeProvider == this ? _navigationRoot : HomeProvider.NavigationRoot; + + /// + public INodeNavigationItem? Parent { get; set; } + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + public string Id { get; } + + /// + public ILeafNavigationItem Index { get; private set; } + + /// + public bool IsUsingNavigationDropdown { get; } + + /// + public IReadOnlyCollection NavigationItems { get; private set; } + + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => SetNavigationItems(navigationItems); + private void SetNavigationItems(IReadOnlyCollection navigationItems) + { + var indexNavigation = navigationItems.QueryIndex(this, $"{PathPrefix}/index.md", out navigationItems); + Index = indexNavigation; + NavigationItems = navigationItems; + _ = this.UpdateNavigationIndex(_context); + } + + + private INavigationItem? ConvertToNavigationItem( + ITableOfContentsItem tocItem, + int index, + IDocumentationSetContext context, + INodeNavigationItem? parent, + INavigationHomeAccessor homeAccessor + ) => + tocItem switch + { + FileRef fileRef => CreateFileNavigation(fileRef, index, context, parent, homeAccessor), + CrossLinkRef crossLinkRef => CreateCrossLinkNavigation(crossLinkRef, index, parent, homeAccessor), + FolderRef folderRef => CreateFolderNavigation(folderRef, index, context, parent, homeAccessor), + IsolatedTableOfContentsRef tocRef => CreateTocNavigation(tocRef, index, context, parent, homeAccessor), + _ => null + }; + + /// + /// Resolves the file info based on the file path. Since LoadAndResolve has already processed paths, + /// we simply combine the documentation source directory with the file path. + /// + private static IFileInfo ResolveFileInfo(IDocumentationSetContext context, string filePath) + { + var fs = context.ReadFileSystem; + // FileRef.Path already contains the correct path from LoadAndResolve + return fs.FileInfo.New(fs.Path.Combine(context.DocumentationSourceDirectory.FullName, filePath)); + } + + /// + /// Creates the documentation file from the factory, emitting an error if creation fails. + /// + private TModel? CreateDocumentationFile( + IFileInfo fileInfo, + IFileSystem fileSystem, + IDocumentationSetContext context, + string fullPath + ) + { + var relativePath = Path.GetRelativePath(context.DocumentationSourceDirectory.FullName, fileInfo.FullName); + var documentationFile = _factory.TryCreateDocumentationFile(fileInfo, fileSystem); + if (documentationFile == null) + context.EmitError(context.ConfigurationPath, $"File navigation '{relativePath}' could not be created. {fullPath}"); + + return documentationFile; + } + + /// + /// Ensures the first item in the navigation items is the index file (index.md or the first file in the list). + /// + private static void EnsureIndexIsFirst(List children) + { + if (children.Count == 0) + return; + + // Find an item named "index" or "index.md" + var indexItem = children.FirstOrDefault(c => + c is ILeafNavigationItem leaf && + (leaf.Model.NavigationTitle.Equals("index", StringComparison.OrdinalIgnoreCase) || + (leaf is FileNavigationLeaf fileLeaf && + fileLeaf.FileInfo.Name.Equals("index.md", StringComparison.OrdinalIgnoreCase)))); + + // If found and it's not already first, move it to the front + if (indexItem != null && children[0] != indexItem) + { + _ = children.Remove(indexItem); + children.Insert(0, indexItem); + } + } + + private INavigationItem? CreateFileNavigation( + FileRef fileRef, + int index, + IDocumentationSetContext context, + INodeNavigationItem? parent, + INavigationHomeAccessor homeAccessor + ) + { + // FileRef.Path already contains the correct path from LoadAndResolve + var fullPath = fileRef.PathRelativeToDocumentationSet; + + // Create file info and documentation file + var fileInfo = fileRef switch + { + DetectionRuleRef ruleRef => ruleRef.FileInfo, + _ => ResolveFileInfo(context, fullPath) + }; + var documentationFile = CreateDocumentationFile(fileInfo, context.ReadFileSystem, context, fullPath); + if (documentationFile == null) + return null; + + // Handle leaf case (no children) + if (fileRef.Children.Count <= 0) + { + var leafNavigationArgs = new FileNavigationArgs(fullPath, fileRef.PathRelativeToContainer, fileRef.Hidden, index, parent, homeAccessor); + return DocumentationNavigationFactory.CreateFileNavigationLeaf(documentationFile, fileInfo, leafNavigationArgs); + } + + // Create file navigation with empty children initially + var virtualFileNavigationArgs = new VirtualFileNavigationArgs( + fullPath, + fileRef.PathRelativeToContainer, + fileRef.Hidden, + index, + parent, + homeAccessor + ); + var fileNavigation = DocumentationNavigationFactory.CreateVirtualFileNavigation(documentationFile, fileInfo, virtualFileNavigationArgs); + + // Process children recursively + var children = new List(); + var childIndex = 0; + + foreach (var child in fileRef.Children) + { + var childNav = ConvertToNavigationItem( + child, childIndex++, context, + fileNavigation, + homeAccessor // Depth will be set by child + ); + if (childNav != null) + children.Add(childNav); + } + + // Validate and order children + if (children.Count < 1) + { + context.EmitError(context.ConfigurationPath, + $"File navigation '{fullPath}' has children defined but none could be created"); + return null; + } + + EnsureIndexIsFirst(children); + fileNavigation.SetNavigationItems(children); + + return fileNavigation; + } + + private INavigationItem? CreateCrossLinkNavigation( + CrossLinkRef crossLinkRef, + int index, + INodeNavigationItem? parent, + INavigationHomeAccessor homeAccessor + ) + { + var title = crossLinkRef.Title ?? crossLinkRef.CrossLinkUri.OriginalString; + if (!_crossLinkResolver.TryResolve(s => _context.EmitError(_context.ConfigurationPath, s), crossLinkRef.CrossLinkUri, out var resolvedUri)) + return null; + var model = new CrossLinkModel(resolvedUri, title); + + return new CrossLinkNavigationLeaf( + model, + resolvedUri.ToString(), + crossLinkRef.Hidden, + parent, + homeAccessor + ) + { + NavigationIndex = index + }; + } + + private INavigationItem? CreateFolderNavigation( + FolderRef folderRef, + int index, + IDocumentationSetContext context, + INodeNavigationItem? parent, + INavigationHomeAccessor homeAccessor + ) + { + // FolderRef.Path already contains the correct path from LoadAndResolve + var folderPath = folderRef.PathRelativeToDocumentationSet; + + // Create folder navigation with null parent initially - we'll pass it to children but set it properly after + var folderNavigation = new FolderNavigation(folderPath, parent, homeAccessor) + { + NavigationIndex = index + }; + + // Process children - they can reference folderNavigation as their parent + var children = new List(); + var childIndex = 0; + + // LoadAndResolve has already populated children (either from YAML or auto-discovered) + foreach (var child in folderRef.Children) + { + var childNav = ConvertToNavigationItem( + child, + childIndex++, + context, + folderNavigation, + homeAccessor + ); + + if (childNav != null) + children.Add(childNav); + } + + // Validate that we have children (LoadAndResolve should have ensured this) + if (children.Count == 0) + { + context.Collector.EmitError(folderRef.Context, $"Folder navigation '{folderPath}' has children defined but none could be created ({folderRef.Context}:)"); + return null; + } + folderNavigation.SetNavigationItems(children); + return folderNavigation; + } + + private INavigationItem? CreateTocNavigation( + IsolatedTableOfContentsRef tocRef, + int index, + IDocumentationSetContext context, + INodeNavigationItem? parent, + INavigationHomeAccessor homeAccessor + ) + { + // tocRef.Path is now the FULL path (e.g., "guides/api" or "setup/advanced") after LoadAndResolve + var fullTocPath = tocRef.PathRelativeToDocumentationSet; + + var tocDirectory = context.ReadFileSystem.DirectoryInfo.New( + context.ReadFileSystem.Path.Combine(context.DocumentationSourceDirectory.FullName, fullTocPath) + ); + + var assemblerBuild = context.AssemblerBuild; + // for assembler builds we ensure toc's create their own home provider sot that they can be re-homed easily + var isolatedHomeProvider = assemblerBuild + ? new NavigationHomeProvider(homeAccessor.HomeProvider.PathPrefix, homeAccessor.HomeProvider.NavigationRoot) + : homeAccessor.HomeProvider; + + // Create the TOC navigation with empty children initially + // We use null parent temporarily - we'll set it properly at the end using the public setter + // Pass tocHomeProvider so the TOC uses parent's NavigationRoot (enables dynamic URL updates) + var tocNavigation = new TableOfContentsNavigation( + tocDirectory, + fullTocPath, + parent, // Temporary null parent + isolatedHomeProvider.PathPrefix, + Git, + _tableOfContentNodes, + isolatedHomeProvider + ) + { + NavigationIndex = index + }; + + // Convert children - pass tocNavigation as parent and tocHomeProvider as HomeProvider (TOC creates new scope) + var children = new List(); + var childIndex = 0; + + //children scoped to documentation-set in isolated builds, to docset in assembler builds + var childHomeAccessor = assemblerBuild ? tocNavigation : homeAccessor; + + foreach (var child in tocRef.Children) + { + var childNav = ConvertToNavigationItem( + child, + childIndex++, + context, + tocNavigation, + childHomeAccessor + ); + + if (childNav != null) + children.Add(childNav); + } + + // Validate TOCs have children + if (children.Count == 0) + { + context.Collector.EmitError(tocRef.Context, + tocRef.Children.Count == 0 + ? $"Table of contents navigation '{fullTocPath}' has no children defined ({tocRef.Context}:)" + : $"Table of contents navigation '{fullTocPath}' has children defined but none could be created ({tocRef.Context}:)"); + return null; + } + tocNavigation.SetNavigationItems(children); + + return tocNavigation; + } + +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs new file mode 100644 index 000000000..13f2b18e7 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs @@ -0,0 +1,55 @@ +// 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.Diagnostics; +using Elastic.Documentation.Extensions; + +namespace Elastic.Documentation.Navigation.Isolated.Node; + +[DebuggerDisplay("{Url}")] +public class FolderNavigation( + string parentPath, + INodeNavigationItem? parent, + INavigationHomeAccessor homeAccessor) + : INodeNavigationItem, IAssignableChildrenNavigation + where TModel : class, IDocumentationFile +{ + // Will be set by SetNavigationItems + + public string FolderPath { get; } = parentPath; + + /// + public string Url => Index.Url; + + /// + public string NavigationTitle => Index.NavigationTitle; + + /// + public IRootNavigationItem NavigationRoot => homeAccessor.HomeProvider.NavigationRoot; + + /// + public INodeNavigationItem? Parent { get; set; } = parent; + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + /// + public string Id { get; } = ShortId.Create(parentPath); + + /// + public ILeafNavigationItem Index { get; private set; } = null!; + + public IReadOnlyCollection NavigationItems { get; private set; } = []; + + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => SetNavigationItems(navigationItems); + internal void SetNavigationItems(IReadOnlyCollection navigationItems) + { + var indexNavigation = navigationItems.QueryIndex(this, $"{FolderPath}/index.md", out navigationItems); + Index = indexNavigation; + NavigationItems = navigationItems; + } +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs new file mode 100644 index 000000000..07349fc7a --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs @@ -0,0 +1,108 @@ +// 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.Diagnostics; +using System.IO.Abstractions; +using Elastic.Documentation.Extensions; + +namespace Elastic.Documentation.Navigation.Isolated.Node; + +[DebuggerDisplay("{Url}")] +public class TableOfContentsNavigation : IRootNavigationItem + , INavigationHomeAccessor + , INavigationHomeProvider + where TModel : class, IDocumentationFile +{ + public TableOfContentsNavigation( + IDirectoryInfo tableOfContentsDirectory, + string parentPath, + INodeNavigationItem? parent, + string pathPrefix, + GitCheckoutInformation git, + Dictionary> tocNodes, + INavigationHomeProvider homeProvider + ) + { + TableOfContentsDirectory = tableOfContentsDirectory; + Parent = parent; + Hidden = false; + IsUsingNavigationDropdown = false; + Id = ShortId.Create(parentPath); + ParentPath = parentPath; + PathPrefix = pathPrefix; + + // Initialize _homeProvider from the provided homeProvider + // According to url-building.md: "In isolated builds the NavigationRoot is always the DocumentationSetNavigation" + HomeProvider = homeProvider; + + // Create an identifier for this TOC + Identifier = new Uri($"{git.RepositoryName}://{parentPath.TrimEnd('/')}"); + _ = tocNodes.TryAdd(Identifier, this); + + // Will be set by SetNavigationItems + Index = null!; + NavigationItems = []; + } + + /// + /// The path prefix for this TOC - same as parent per url-building.md. + /// Implements INavigationHomeProvider.PathPrefix. + /// TOC doesn't change PathPrefix from the parent. + /// + public string PathPrefix { get; } + + /// + public string Url => Index.Url; + + /// + public string NavigationTitle => Index.NavigationTitle; + + /// + /// TableOfContentsNavigation's NavigationRoot comes from its HomeProvider. + /// According to url-building.md: "In isolated builds the NavigationRoot is always the DocumentationSetNavigation" + /// This satisfies both INavigationItem.NavigationRoot and INavigationHomeProvider.NavigationRoot. + /// + public IRootNavigationItem NavigationRoot => HomeProvider.NavigationRoot; + + /// + public INodeNavigationItem? Parent { get; set; } + + /// + /// TableOfContentsNavigation implements INavigationHomeProvider and provides itself + /// as the home provider for its children by default. This creates the scoped navigation context. + /// The setter is used in assembler builds to rehome the navigation. + /// + public INavigationHomeProvider HomeProvider { get; set; } + + /// + public bool Hidden { get; } + + /// + public int NavigationIndex { get; set; } + + public string ParentPath { get; } + + /// + public string Id { get; } + + /// + public ILeafNavigationItem Index { get; private set; } + + /// + public bool IsUsingNavigationDropdown { get; } + + public IDirectoryInfo TableOfContentsDirectory { get; } + + public Uri Identifier { get; } + + public IReadOnlyCollection NavigationItems { get; private set; } + + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => SetNavigationItems(navigationItems); + internal void SetNavigationItems(IReadOnlyCollection navigationItems) + { + var indexNavigation = navigationItems.QueryIndex(this, $"{ParentPath}/index.md", out navigationItems); + Index = indexNavigation; + NavigationItems = navigationItems; + } +} diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/VirtualFileNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/VirtualFileNavigation.cs new file mode 100644 index 000000000..5e5e17904 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/VirtualFileNavigation.cs @@ -0,0 +1,47 @@ +// 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.Diagnostics; +using System.IO.Abstractions; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Isolated.Leaf; + +namespace Elastic.Documentation.Navigation.Isolated.Node; + +/// Represents a file navigation item that defines children which are not part of the file tree. +[DebuggerDisplay("{Url}")] +public class VirtualFileNavigation(TModel model, IFileInfo fileInfo, VirtualFileNavigationArgs args) + : INodeNavigationItem, IAssignableChildrenNavigation + where TModel : IDocumentationFile +{ + /// + public string Url => Index.Url; + + /// + public string NavigationTitle => Index.NavigationTitle; + + /// + public IRootNavigationItem NavigationRoot => args.HomeAccessor.HomeProvider.NavigationRoot; + + /// + public INodeNavigationItem? Parent { get; set; } = args.Parent; + + /// + public bool Hidden { get; } = args.Hidden; + + /// + public int NavigationIndex { get; set; } + + /// + public string Id { get; } = ShortId.Create(args.RelativePathToDocumentationSet); + + /// + public ILeafNavigationItem Index { get; } = + new FileNavigationLeaf(model, fileInfo, new FileNavigationArgs(args.RelativePathToDocumentationSet, args.RelativePathToTableOfContents, args.Hidden, args.NavigationIndex, args.Parent, args.HomeAccessor)); + + public IReadOnlyCollection NavigationItems { get; private set; } = []; + + void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection navigationItems) => SetNavigationItems(navigationItems); + internal void SetNavigationItems(IReadOnlyCollection navigationItems) => NavigationItems = navigationItems; +} diff --git a/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs new file mode 100644 index 000000000..8ea0d6908 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs @@ -0,0 +1,74 @@ +// 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 Elastic.Documentation.Navigation.Isolated; + +namespace Elastic.Documentation.Navigation; + +public static class NavigationItemExtensions +{ + public static ILeafNavigationItem QueryIndex( + this IReadOnlyCollection items, INodeNavigationItem node, string fallbackPath, out IReadOnlyCollection children + ) + where TModel : class, IDocumentationFile + { + var index = LookupIndex(); + + children = items.Except([index]).ToArray(); + + return index; + + ILeafNavigationItem LookupIndex() + { + foreach (var item in items) + { + // Check for the exact type match + if (item is ILeafNavigationItem leaf) + return leaf; + + // Check if this is a node navigation item and return its index + if (item is INodeNavigationItem nodeItem) + return nodeItem.Index; + } + + // If no index is found, throw an exception + throw new InvalidOperationException($"No index found for navigation node '{node.GetType().Name}' at path '{fallbackPath}'"); + } + } + + public static int UpdateNavigationIndex(this IRootNavigationItem node, IDocumentationContext context) + where TModel : IDocumentationFile + { + var navigationIndex = -1; + ProcessNavigationItem(context, ref navigationIndex, node); + return navigationIndex; + + } + + private static void UpdateNavigationIndex(IReadOnlyCollection navigationItems, IDocumentationContext context, ref int navigationIndex) + { + foreach (var item in navigationItems) + ProcessNavigationItem(context, ref navigationIndex, item); + } + + private static void ProcessNavigationItem(IDocumentationContext context, ref int navigationIndex, INavigationItem item) + { + switch (item) + { + case ILeafNavigationItem leaf: + var fileIndex = Interlocked.Increment(ref navigationIndex); + leaf.NavigationIndex = fileIndex; + break; + case INodeNavigationItem node: + var groupIndex = Interlocked.Increment(ref navigationIndex); + node.NavigationIndex = groupIndex; + node.Index.NavigationIndex = groupIndex; + UpdateNavigationIndex(node.NavigationItems, context, ref navigationIndex); + break; + default: + context.EmitError(context.ConfigurationPath, $"{nameof(UpdateNavigationIndex)}: Unhandled navigation item type: {item.GetType()}"); + break; + } + } +} diff --git a/src/Elastic.Documentation.Navigation/README.md b/src/Elastic.Documentation.Navigation/README.md new file mode 100644 index 000000000..e526cecdb --- /dev/null +++ b/src/Elastic.Documentation.Navigation/README.md @@ -0,0 +1,331 @@ +# Navigation Documentation + +Welcome to the documentation for `Elastic.Documentation.Navigation`, the library that powers documentation navigation for Elastic's documentation sites. + +## What This Is + +This library builds hierarchical navigation trees for documentation sites with a unique capability: navigation built for isolated repositories can be **efficiently re-homed** during site assembly without rebuilding the entire tree. + +**Why does this matter?** + +Individual documentation teams can build and test their docs in isolation with URLs like `/api/overview/`, then those same docs can be assembled into a unified site with URLs like `/elasticsearch/api/overview/` - with **zero tree reconstruction**. It's an O(1) operation. + +## Documentation Map + +Start with any document based on what you want to learn: + +### 🎯 [navigation.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/navigation.md) - Start Here +**Overview of the navigation system** + +Read this first to understand: +- The two build modes (isolated vs assembler) +- Core concepts at a high level +- Quick introduction to re-homing +- Links to detailed documentation + +### 🎨 [visual-walkthrough.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/visual-walkthrough.md) - See It In Action +**Visual tour with diagrams showing navigation structures** + +Read this to understand: +- What different node types look like in the tree +- How isolated builds differ from assembler builds visually +- How the same content appears with different URLs +- How to split and reorganize documentation across sites +- Common patterns for multi-repository organization +- Includes actual tree diagrams from this repository + +### 🧭 [first-principles.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/first-principles.md) - Design Philosophy +**Core principles that guide the architecture** + +Read this to understand: +- Why two-phase loading (configuration → navigation) +- Why URLs are calculated dynamically, not stored +- Why navigation roots can be re-homed +- Design patterns used (factory, provider, visitor) +- Performance characteristics and invariants + +### 🔄 [two-phase-loading.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/two-phase-loading.md) - The Loading Process +**Deep dive into Phase 1 (configuration) and Phase 2 (navigation)** + +Read this to understand: +- What happens in Phase 1: Configuration resolution +- What happens in Phase 2: Navigation construction +- Why these phases are separate +- Data flow diagrams +- How to test each phase independently + +### 🏠 [home-provider-architecture.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/home-provider-architecture.md) - The Re-homing Magic +**How O(1) re-homing works** + +Read this to understand: +- The problem: naive re-homing requires O(n) tree traversal +- The solution: HomeProvider pattern with indirection +- How `INavigationHomeProvider` and `INavigationHomeAccessor` work +- Why URLs are lazily calculated and cached +- Detailed examples of re-homing in action +- Performance analysis + +**This is the most important technical concept in the system.** + +### 📦 [node-types.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/node-types.md) - Node Type Reference +**Complete reference for every navigation node type** + +Read this to understand: +- All 7 node types in detail: + - **Leaves**: FileNavigationLeaf, CrossLinkNavigationLeaf + - **Nodes**: FolderNavigation, VirtualFileNavigation + - **Roots**: DocumentationSetNavigation, TableOfContentsNavigation, SiteNavigation +- Constructor signatures +- URL calculation for each type +- Factory methods +- Model types (IDocumentationFile) + +### 🔨 [assembler-process.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/assembler-process.md) - Building Unified Sites +**How multiple repositories become one site** + +Read this to understand: +- The assembler build process step-by-step +- How `SiteNavigation` works +- Re-homing in practice during assembly +- Path prefix requirements +- Phantom nodes +- Nested re-homing +- Error handling + +## Suggested Reading Order + +**If you're new to the codebase:** +1. [navigation.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/navigation.md) - Get the overview +2. [visual-walkthrough.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/visual-walkthrough.md) - See it visually +3. [first-principles.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/first-principles.md) - Understand the why +4. [home-provider-architecture.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/home-provider-architecture.md) - Understand the how +5. [node-types.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/node-types.md) - Reference as needed + +**If you're debugging an issue:** +1. [node-types.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/node-types.md) - Find the node type +2. [home-provider-architecture.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/home-provider-architecture.md) - Understand URL calculation +3. [two-phase-loading.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/two-phase-loading.md) - Check which phase + +**If you're adding a feature:** +1. [first-principles.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/first-principles.md) - Ensure design consistency +2. [node-types.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/node-types.md) - See existing patterns +3. [two-phase-loading.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/two-phase-loading.md) - Determine which phase +4. [assembler-process.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/assembler-process.md) - Consider assembler impact + +**If you're optimizing performance:** +1. [home-provider-architecture.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/home-provider-architecture.md) - Understand caching +2. [first-principles.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/first-principles.md) - See performance characteristics +3. [two-phase-loading.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/two-phase-loading.md) - Find expensive operations + +## Key Concepts Summary + +### Two Build Modes + +1. **Isolated Build** + - Single repository + - URLs relative to `/` + - `DocumentationSetNavigation` is the root + - Fast iteration for doc teams + +2. **Assembler Build** + - Multiple repositories + - Custom URL prefixes + - `SiteNavigation` is the root + - Docsets/TOCs are re-homed + +### Two-Phase Loading + +1. **Phase 1: Configuration** (`Elastic.Documentation.Configuration`) + - Parse YAML files + - Resolve all relative paths to absolute paths from docset root + - Validate structure and file references + - Load nested `toc.yml` files + - Output: Fully resolved configuration + +2. **Phase 2: Navigation** (`Elastic.Documentation.Navigation`) + - Build tree from resolved configuration + - Establish parent-child relationships + - Set up home providers + - Calculate navigation indexes + - Output: Complete navigation tree + +### Home Provider Pattern + +The secret to O(1) re-homing: + +```csharp +// Provider defines URL context +public interface INavigationHomeProvider +{ + string PathPrefix { get; } + IRootNavigationItem<...> NavigationRoot { get; } +} + +// Accessor references provider +public interface INavigationHomeAccessor +{ + INavigationHomeProvider HomeProvider { get; set; } +} + +// Nodes calculate URLs from current provider +public string Url => + $"{_homeAccessor.HomeProvider.PathPrefix}/{_relativePath}/"; +``` + +**Re-homing:** +```csharp +// Change provider → all URLs update instantly! +node.HomeProvider = new NavigationHomeProvider("/new-prefix", newRoot); +``` + +### Node Types + +7 types organized by capabilities: + +**Leaves** (no children): +- `FileNavigationLeaf` - Markdown file +- `CrossLinkNavigationLeaf` - External link + +**Nodes** (have children): +- `FolderNavigation` - Directory +- `VirtualFileNavigation` - File with YAML-defined children + +**Roots** (can be re-homed): +- `DocumentationSetNavigation` - Docset root +- `TableOfContentsNavigation` - Nested TOC +- `SiteNavigation` - Assembled site root + +## Code Organization + +The library is organized into: + +### `Elastic.Documentation.Navigation/` +Root namespace - shared types: +- `IDocumentationFile.cs` - Base interface for documentation files +- `NavigationModels.cs` - Common model types (CrossLinkModel, SiteNavigationNoIndexFile) + +### `Elastic.Documentation.Navigation/Isolated/` +Isolated build navigation: +- `DocumentationSetNavigation.cs` - Docset root +- `TableOfContentsNavigation.cs` - Nested TOC +- `FolderNavigation.cs` - Folder nodes +- `FileNavigationLeaf.cs` - File leaves +- `VirtualFileNavigation.cs` - Virtual file nodes +- `CrossLinkNavigationLeaf.cs` - Crosslink leaves +- `DocumentationNavigationFactory.cs` - Factory for creating nodes +- `NavigationArguments.cs` - Constructor argument records +- `NavigationHomeProvider.cs` - Home provider implementation + +### `Elastic.Documentation.Navigation/Assembler/` +Assembler build navigation: +- `SiteNavigation.cs` - Unified site root + +### Supporting Files +- `README.md` - High-level overview (in src/) +- `url-building.md` - URL building rules (in src/) + +## Testing + +Tests are in `tests/Navigation.Tests/`: + +**Isolated build tests:** +- `Isolation/ConstructorTests.cs` - Basic navigation construction +- `Isolation/FileNavigationTests.cs` - File leaf behavior +- `Isolation/FolderIndexFileRefTests.cs` - Folder navigation +- `Isolation/PhysicalDocsetTests.cs` - Real docset loading + +**Assembler build tests:** +- `Assembler/SiteNavigationTests.cs` - Site assembly +- `Assembler/SiteDocumentationSetsTests.cs` - Multiple docsets +- `Assembler/ComplexSiteNavigationTests.cs` - Complex scenarios + +**Test pattern:** +```csharp +[Fact] +public void FeatureUnderTest_Scenario_ExpectedBehavior() +{ + // Arrange: Create mock file system and configuration + var fileSystem = new MockFileSystem(); + var config = CreateConfig(...); + + // Act: Build navigation + var nav = new DocumentationSetNavigation(...); + + // Assert: Verify behavior + Assert.Equal("/expected/url/", nav.Index.Url); +} +``` + +## Common Tasks + +### Adding a New Node Type + +1. Create class in `Isolated/` namespace +2. Implement appropriate interface (`ILeafNavigationItem` or `INodeNavigationItem`) +3. Add factory method if needed +4. Update `ConvertToNavigationItem` in `DocumentationSetNavigation` +5. Add tests in `Isolation/` +6. Update [node-types.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/node-types.md) + +### Changing URL Calculation + +1. Review [first-principles.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/first-principles.md) - ensure consistency +2. Update `FileNavigationLeaf.Url` property +3. Consider cache invalidation +4. Update tests +5. Update [home-provider-architecture.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/home-provider-architecture.md) + +### Modifying Configuration + +1. Update classes in `Elastic.Documentation.Configuration` +2. Update `LoadAndResolve` methods +3. Update Phase 2 consumption in navigation classes +4. Update tests for both phases +5. Update [two-phase-loading.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/two-phase-loading.md) + +### Debugging Re-homing Issues + +1. Check `HomeProvider` assignments in [assembler-process.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/assembler-process.md) +2. Verify `PathPrefix` values +3. Check `NavigationRoot` points to correct root +4. Look for cache issues (HomeProvider ID changed?) +5. Review [home-provider-architecture.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/home-provider-architecture.md) + +## Related Documentation + +- `Elastic.Documentation.Configuration` - Phase 1 (configuration resolution) +- `Elastic.Documentation.Links` - Cross-link resolution +- `Elastic.Markdown` - Markdown processing + +## Source Reference + +For the actual implementation, see: +- Library: `src/Elastic.Documentation.Navigation/` +- Tests: `tests/Navigation.Tests/` +- Configuration: `src/Elastic.Documentation.Configuration/` + +## Contributing + +When making changes: + +1. **Maintain invariants** from [first-principles.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/first-principles.md) +2. **Keep phases separate** - don't mix configuration and navigation +3. **Preserve O(1) re-homing** - don't add tree traversals +4. **Add tests** for both isolated and assembler scenarios +5. **Update documentation** in `docs/development/navigation/` +6. **Run all 111+ tests** - they should all pass + +## Questions? + +- **"How do URLs get calculated?"** → [home-provider-architecture.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/home-provider-architecture.md) +- **"Why two phases?"** → [two-phase-loading.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/two-phase-loading.md) +- **"What is re-homing?"** → [navigation.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/navigation.md) then [home-provider-architecture.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/home-provider-architecture.md) +- **"Which node type do I need?"** → [node-types.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/node-types.md) +- **"How does the assembler work?"** → [assembler-process.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/assembler-process.md) +- **"What are the design principles?"** → [first-principles.md](https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/development/navigation/first-principles.md) + +--- + +**Welcome to Elastic.Documentation.Navigation!** + +The library that makes it possible to build documentation in isolation and efficiently assemble it into unified sites with custom URL structures - no rebuilding required. 🚀 diff --git a/src/Elastic.Documentation.Navigation/url-building.md b/src/Elastic.Documentation.Navigation/url-building.md new file mode 100644 index 000000000..6b788155a --- /dev/null +++ b/src/Elastic.Documentation.Navigation/url-building.md @@ -0,0 +1,69 @@ +# Url Building + +There are two modes of building a navigation + +## Isolated + +### DocumentationSetNavigation + +Is the root of the navigation for isolated builds: + +Url is `path_prefix` from `BuildContext` + `/` from `DocumentationSet` + +### TableOfContentsNavigation + +Models a rehomable portion of the navigation. + +Url is `path_prefix` from `BuildContext` + `folder/path` to the `toc.yml` file defining the `TableOfContentsNavigation` + +`TableOfContentsNavigation` needs to create a new scope for its children utilizing a new instance of `INavigationHomeAccessor` using `this` as the `INavigationHomeProvider`. + +We are not actually changing the PathPrefix, we create the scope to be able to rehome during `Assembler` builds. + +### Navigation Scopes + +DocumentationSetNavigation and TableOfContentsNavigation create a new scope for the navigation using `INavigationHomeProvider` children use `INavigationHomeAccessor` to access their scope's `PathPrefix` and `NavigationRoot`. In isolated builds the `NavigationRoot` is always the `DocumentationSetNavigation`. + +`INavigationHomeAccessor` needs to be passed down the navigation tree to ensure we can calculate `Url` and `NavigationRoot` dynamically. + +#### FileNavigationLeaf + +Url is `path_prefix` from `DocumentationSetNavigation` + `path` to the markdown file. +FileNavigationLeaf utilizes `INavigationHomeAccessor` to query for `path_prefix` and `NavigationRoot`. +Rules for `path`: + * Relative to `DocumentationSet` (use `FileRef.Path`) + * if the name is `index.md` then the `path` is only the folder portion. + * Otherwise, include the name of the file without its extension. + +### FolderNavigation + +Url is the url of its `Index` property. + +### VirtualFileNavigation + +Url is the url of its `Index` property. + +### CrossLinkNavigationLeaf + +Url is the resolved url of `CrossLinkRef.CrossLinkUri` using `ICrossLinkResolver`. + + +## Assembler + +The assembler is responsible for building the navigation for the entire site. +It utilizes `SiteNavigation` to build its own navigation. + +`SiteNavigation` receives a set of composed `DocumentationSets` and utilizes `navigation.yml` (`NavigationFile`) to restructure the navigation. `SiteNavigation` holds all `DocumentationSetNavigation` and `TableOfContentsNavigation` under `_nodes` and uses the references to build its own navigation. + +Using `path_prefix` defined in `navigation.yml` it will update the `PathPrefix` and `NavigationRoot` of the `DocumentationSetNavigation` and `TableOfContentsNavigation`. + +The NavigationRoot anything under the top level navigation itemsis the top level navigation item. + +The root of top level navigation items is the `DocumentationSetNavigation`. + + +`SiteNavigation` also steals the top level leaf navigation nodes from the `NarrativeRepository.RepositoryName` DocumentationSetNavigation to act as its own top level navigation items. + + + + diff --git a/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj b/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj index e4fdf20e8..a0036194f 100644 --- a/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj +++ b/src/Elastic.Documentation.Site/Elastic.Documentation.Site.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs b/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs index 3f598bcac..2e84259e3 100644 --- a/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs +++ b/src/Elastic.Documentation.Site/Navigation/INavigationHtmlWriter.cs @@ -2,6 +2,7 @@ // 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 Elastic.Documentation.Navigation; using RazorSlices; namespace Elastic.Documentation.Site.Navigation; @@ -10,7 +11,12 @@ public interface INavigationHtmlWriter { const int AllLevels = -1; - Task RenderNavigation(IRootNavigationItem currentRootNavigation, int maxLevel, Cancel ctx = default); + Task RenderNavigation( + IRootNavigationItem currentRootNavigation, + INavigationItem currentNavigationItem, + int maxLevel, + Cancel ctx = default + ); async Task Render(NavigationViewModel model, Cancel ctx) { diff --git a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs index 198405d18..441242b41 100644 --- a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs +++ b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using Elastic.Documentation.Configuration; using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation; namespace Elastic.Documentation.Site.Navigation; @@ -14,7 +15,7 @@ public class IsolatedBuildNavigationHtmlWriter(BuildContext context, IRootNaviga private readonly ConcurrentDictionary<(string, int), string> _renderedNavigationCache = []; public async Task RenderNavigation( - IRootNavigationItem currentRootNavigation, int maxLevel, Cancel ctx = default + IRootNavigationItem currentRootNavigation, INavigationItem currentNavigationItem, int maxLevel, Cancel ctx = default ) { var navigation = context.Configuration.Features.PrimaryNavEnabled || currentRootNavigation.IsUsingNavigationDropdown diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs b/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs index 8d05df7ca..f5b074d6f 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs @@ -2,6 +2,8 @@ // 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 Elastic.Documentation.Navigation; + namespace Elastic.Documentation.Site.Navigation; public class NavigationTreeItem diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs b/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs index 0c21cf7a0..035f69e89 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs @@ -2,6 +2,8 @@ // 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 Elastic.Documentation.Navigation; + namespace Elastic.Documentation.Site.Navigation; public class NavigationViewModel diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml index d54da09ea..42b593a21 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml @@ -5,7 +5,7 @@ @{ var currentTopLevelItem = Model.TopLevelItems.FirstOrDefault(i => i.Id == Model.Tree.Id) ?? Model.Tree; } - @if (Model.IsUsingNavigationDropdown && currentTopLevelItem is { Index: not null }) + @if (Model.IsUsingNavigationDropdown) {
diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index 213016686..5a64551fd 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -1,3 +1,6 @@ +@using Elastic.Documentation.Navigation +@using Elastic.Documentation.Navigation.Isolated +@using Elastic.Documentation.Navigation.Isolated.Leaf @using Elastic.Documentation.Site.Navigation @inherits RazorSlice @{ @@ -9,7 +12,12 @@ { continue; } - if (item is INodeNavigationItem { NavigationItems.Count: 0, Index: not null } group) + + if (item.Parent is not null && item.Parent.Index == item) + { + continue; + } + if (item is INodeNavigationItem { NavigationItems.Count: 0 } group) {
  • leaf) { - var hasSameTopLevelGroup = !leaf.IsCrossLink && (Model.IsPrimaryNavEnabled && leaf.NavigationRoot.Id == Model.RootNavigationId || true); + var hasSameTopLevelGroup = leaf.Model is not CrossLinkModel && (Model.IsPrimaryNavEnabled && leaf.NavigationRoot.Id == Model.RootNavigationId || true);
  • { .getByRole('link', { name: 'Elastic Fundamentals' }) .first() .click() - await expect(page).toHaveURL(`${host}/docs/get-started/`) + await expect(page).toHaveURL(`${host}/docs/get-started`) await expect(page).toHaveTitle(/Elastic fundamentals/) await expect( page.getByRole('heading', { name: 'Elastic fundamentals' }) @@ -71,6 +71,6 @@ journey('navigation test', ({ page, params }) => { await pagesDropdown .getByRole('link', { name: 'Reference', exact: true }) .click() - await expect(page).toHaveURL(`${host}/docs/reference/`) + await expect(page).toHaveURL(`${host}/docs/reference`) }) }) diff --git a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs index 5a0914d9d..5207c58c6 100644 --- a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs +++ b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs @@ -31,7 +31,7 @@ public class DiagnosticsCollector(IReadOnlyCollection output public bool NoHints { get; set; } - public DiagnosticsCollector StartAsync(Cancel ctx) + public virtual DiagnosticsCollector StartAsync(Cancel ctx) { _ = ((IHostedService)this).StartAsync(ctx); return this; @@ -75,7 +75,7 @@ void Drain() } } - private void IncrementSeverityCount(Diagnostic item) + protected void IncrementSeverityCount(Diagnostic item) { if (item.Severity == Severity.Error) _ = Interlocked.Increment(ref _errors); @@ -97,7 +97,7 @@ public virtual async Task StopAsync(Cancel cancellationToken) public void EmitCrossLink(string link) => CrossLinks.Add(link); - public void Write(Diagnostic diagnostic) + public virtual void Write(Diagnostic diagnostic) { IncrementSeverityCount(diagnostic); Channel.Write(diagnostic); diff --git a/src/Elastic.Documentation/Diagnostics/HintType.cs b/src/Elastic.Documentation/Diagnostics/HintType.cs new file mode 100644 index 000000000..6e4894d38 --- /dev/null +++ b/src/Elastic.Documentation/Diagnostics/HintType.cs @@ -0,0 +1,23 @@ +// 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 + +namespace Elastic.Documentation.Diagnostics; + +/// +/// Types of hints that can be emitted during documentation processing. +/// +public enum HintType +{ + /// + /// Hint about deep-linking virtual files (files with paths that have children). + /// Suggests using 'folder' instead of 'file' for better navigation structure. + /// + DeepLinkingVirtualFile, + + /// + /// Hint about file name not matching folder name in folder+file combinations. + /// Best practice is to name the file the same as the folder. + /// + FolderFileNameMismatch +} diff --git a/src/Elastic.Documentation/Diagnostics/HintTypeExtensions.cs b/src/Elastic.Documentation/Diagnostics/HintTypeExtensions.cs new file mode 100644 index 000000000..b51657db0 --- /dev/null +++ b/src/Elastic.Documentation/Diagnostics/HintTypeExtensions.cs @@ -0,0 +1,20 @@ +// 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 + +namespace Elastic.Documentation.Diagnostics; + +/// +/// Extension methods for working with HintType suppressions. +/// +public static class HintTypeExtensions +{ + /// + /// Checks if a specific hint type should be suppressed. + /// + /// The set of suppressed hint types. + /// The hint type to check. + /// True if the hint should be suppressed, false otherwise. + public static bool ShouldSuppress(this HashSet? suppressions, HintType hintType) => + suppressions?.Contains(hintType) == true; +} diff --git a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs index 38cf92d35..a81b27f83 100644 --- a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs +++ b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs @@ -28,12 +28,44 @@ public static bool IsSubPathOf(this IFileInfo file, IDirectoryInfo parentDirecto return parent is not null && parent.IsSubPathOf(parentDirectory); } + public static IFileInfo EnsureSubPathOf(this IFileInfo file, IDirectoryInfo parentDirectory, string relativePath) + { + var fs = file.FileSystem; + List intermediaryDirectories = ["x"]; + while (!file.IsSubPathOf(parentDirectory)) + { + var path = Path.GetFullPath(fs.Path.Combine([parentDirectory.FullName, .. intermediaryDirectories, relativePath])); + file = fs.FileInfo.New(path); + intermediaryDirectories.Add("x"); + } + + return file; + } + /// Checks if has parent directory , defaults to OrdinalIgnoreCase comparison public static bool HasParent(this IFileInfo file, string parentName) { var parent = file.Directory; return parent is not null && parent.HasParent(parentName); } + + public static IFileInfo NewCombine(this IFileInfoFactory fileInfo, params string[] paths) + { + paths = paths.Select(f => f.OptionalWindowsReplace()).ToArray(); + var fi = fileInfo.New(Path.Combine(paths)); + return fi; + } +} + +public static class IFileSystemExtensions +{ + public static IDirectoryInfo NewDirInfo(this IFileSystem fs, string path) => fs.DirectoryInfo.New(path); + + public static IDirectoryInfo NewDirInfo(this IFileSystem fs, params string[] paths) => fs.DirectoryInfo.New(Path.Combine(paths)); + + public static IFileInfo NewFileInfo(this IFileSystem fs, string path) => fs.FileInfo.NewCombine(path); + + public static IFileInfo NewFileInfo(this IFileSystem fs, params string[] paths) => fs.FileInfo.NewCombine(paths); } public static class IDirectoryInfoExtensions @@ -95,4 +127,20 @@ public static bool HasParent(this IDirectoryInfo directory, string parentName, S return false; } + + /// Gets the first , parent of + public static IDirectoryInfo? GetParent(this IDirectoryInfo directory, string parentName, StringComparison comparison = OrdinalIgnoreCase) + { + if (string.Equals(directory.Name, parentName, comparison)) + return directory; + var parent = directory; + do + { + if (string.Equals(parent.Name, parentName, comparison)) + return parent; + parent = parent.Parent; + } while (parent != null); + + return null; + } } diff --git a/src/Elastic.Documentation/Extensions/StringExtensions.cs b/src/Elastic.Documentation/Extensions/StringExtensions.cs new file mode 100644 index 000000000..5a5daf85c --- /dev/null +++ b/src/Elastic.Documentation/Extensions/StringExtensions.cs @@ -0,0 +1,17 @@ +// 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.Runtime.InteropServices; + +namespace Elastic.Documentation.Extensions; + +public static class StringExtensions +{ + public static string OptionalWindowsReplace(this string relativePath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + relativePath = relativePath.Replace('\\', '/'); + return relativePath; + } +} diff --git a/src/Elastic.Documentation/GitCheckoutInformation.cs b/src/Elastic.Documentation/GitCheckoutInformation.cs index 729377bb3..aa0dda3b7 100644 --- a/src/Elastic.Documentation/GitCheckoutInformation.cs +++ b/src/Elastic.Documentation/GitCheckoutInformation.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Elastic.Documentation.Extensions; using Microsoft.Extensions.Logging; using SoftCircuits.IniFileParser; @@ -48,23 +49,39 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem RepositoryName = "docs-builder" }; } - var fakeRef = Guid.NewGuid().ToString()[..16]; - var gitConfig = Git(source, Path.Combine(".git", "config")); + + var gitDir = GitDir(source, ".git"); + if (!gitDir.Exists) + { + // try a worktree .git file + var worktreeFile = Git(source, ".git"); + if (!worktreeFile.Exists) + return Unavailable; + var workTreePath = Read(source, ".git")?.Replace("gitdir: ", string.Empty); + if (workTreePath is null) + return Unavailable; + //TODO read branch info from worktree do not fall through + gitDir = fileSystem.DirectoryInfo.New(workTreePath).GetParent(".git"); + if (gitDir is null || !gitDir.Exists) + return Unavailable; + } + + var gitConfig = Git(gitDir, "config"); if (!gitConfig.Exists) { logger?.LogInformation("Git checkout information not available."); return Unavailable; } - var head = Read(source, Path.Combine(".git", "HEAD")) ?? fakeRef; + var head = Read(gitDir, "HEAD") ?? fakeRef; var gitRef = head; var branch = head.Replace("refs/heads/", string.Empty); //not detached HEAD if (head.StartsWith("ref:", StringComparison.OrdinalIgnoreCase)) { head = head.Replace("ref: ", string.Empty); - gitRef = Read(source, Path.Combine(".git", head)) ?? fakeRef; + gitRef = Read(gitDir, head) ?? fakeRef; branch = branch.Replace("ref: ", string.Empty); } else @@ -117,6 +134,9 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem IFileInfo Git(IDirectoryInfo directoryInfo, string path) => fileSystem.FileInfo.New(Path.Combine(directoryInfo.FullName, path)); + IDirectoryInfo GitDir(IDirectoryInfo directoryInfo, string path) => + fileSystem.DirectoryInfo.New(Path.Combine(directoryInfo.FullName, path)); + string? Read(IDirectoryInfo directoryInfo, string path) { var gitPath = Git(directoryInfo, path).FullName; diff --git a/src/Elastic.Documentation/IDocumentationContext.cs b/src/Elastic.Documentation/IDocumentationContext.cs index f21e8568c..6ffb4a36c 100644 --- a/src/Elastic.Documentation/IDocumentationContext.cs +++ b/src/Elastic.Documentation/IDocumentationContext.cs @@ -13,21 +13,24 @@ public interface IDocumentationContext IFileSystem ReadFileSystem { get; } IFileSystem WriteFileSystem { get; } IDirectoryInfo OutputDirectory { get; } + IFileInfo ConfigurationPath { get; } + bool AssemblerBuild { get; } } public interface IDocumentationSetContext : IDocumentationContext { IDirectoryInfo DocumentationSourceDirectory { get; } GitCheckoutInformation Git { get; } - IFileInfo ConfigurationPath { get; } } public static class DocumentationContextExtensions { - public static void EmitError(this IDocumentationSetContext context, IFileInfo file, string message, Exception? e = null) => + public static void EmitError(this IDocumentationContext context, IFileInfo file, string message, Exception? e = null) => context.Collector.EmitError(file, message, e); - public static void EmitWarning(this IDocumentationSetContext context, IFileInfo file, string message) => + public static void EmitWarning(this IDocumentationContext context, IFileInfo file, string message) => context.Collector.EmitWarning(file, message); + public static void EmitHint(this IDocumentationContext context, IFileInfo file, string message) => + context.Collector.EmitHint(file, message); } diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 5aeb99861..99de500eb 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -9,7 +9,6 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Links; -using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Serialization; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; @@ -49,7 +48,6 @@ public class DocumentationGenerator public DocumentationSet DocumentationSet { get; } public BuildContext Context { get; } - public ICrossLinkResolver CrossLinkResolver { get; } public IMarkdownStringRenderer MarkdownStringRenderer => HtmlWriter; public DocumentationGenerator( @@ -59,8 +57,7 @@ public DocumentationGenerator( IDocumentationFileOutputProvider? documentationFileOutputProvider = null, IMarkdownExporter[]? markdownExporters = null, IConversionCollector? conversionCollector = null, - ILegacyUrlMapper? legacyUrlMapper = null, - IPositionalNavigation? positionalNavigation = null + ILegacyUrlMapper? legacyUrlMapper = null ) { _markdownExporters = markdownExporters ?? []; @@ -71,10 +68,8 @@ public DocumentationGenerator( DocumentationSet = docSet; Context = docSet.Context; - CrossLinkResolver = docSet.CrossLinkResolver; var productVersionInferrer = new ProductVersionInferrerService(DocumentationSet.Context.ProductsConfiguration, DocumentationSet.Context.VersionsConfiguration); - HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem, new DescriptionGenerator(), navigationHtmlWriter, legacyUrlMapper, - positionalNavigation, productVersionInferrer); + HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem, new DescriptionGenerator(), navigationHtmlWriter, legacyUrlMapper, productVersionInferrer); _documentationFileExporter = docSet.Context.AvailableExporters.Contains(Exporter.Html) ? docSet.EnabledExtensions.FirstOrDefault(e => e.FileExporter != null)?.FileExporter @@ -157,9 +152,10 @@ private async Task ProcessDocumentationFiles(HashSet offendingFiles, Dat await Parallel.ForEachAsync(DocumentationSet.Files, ctx, async (file, token) => { var processedFiles = Interlocked.Increment(ref processedFileCount); + var (fp, doc) = file; try { - await ProcessFile(offendingFiles, file, outputSeenChanges, token); + await ProcessFile(offendingFiles, doc, outputSeenChanges, token); } catch (Exception e) { @@ -167,15 +163,15 @@ await Parallel.ForEachAsync(DocumentationSet.Files, ctx, async (file, token) => // this is not the main error logging mechanism // if we hit this from too many files fail hard if (currentCount <= 25) - Context.Collector.EmitError(file.RelativePath, "Uncaught exception while processing file", e); + Context.Collector.EmitError(fp.RelativePath, "Uncaught exception while processing file", e); else throw; } if (processedFiles % 100 == 0) - _logger.LogInformation("-> Processed {ProcessedFiles}/{TotalFileCount} files", processedFiles, totalFileCount); + _logger.LogInformation(" {Name} -> Processed {ProcessedFiles}/{TotalFileCount} files", Context.Git.RepositoryName, processedFiles, totalFileCount); }); - _logger.LogInformation("-> Processed {ProcessedFileCount}/{TotalFileCount} files", processedFileCount, totalFileCount); + _logger.LogInformation(" {Name} -> Processed {ProcessedFileCount}/{TotalFileCount} files", Context.Git.RepositoryName, processedFileCount, totalFileCount); } @@ -257,7 +253,8 @@ private async Task ProcessFile(HashSet offendingFiles, DocumentationFile { foreach (var exporter in _markdownExporters) { - var document = context.MarkdownDocument ??= await markdown.ParseFullAsync(ctx); + var document = context.MarkdownDocument ??= await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); + var navigationItem = DocumentationSet.FindNavigationByMarkdown(markdown); _ = await exporter.ExportAsync(new MarkdownExportFileContext { BuildContext = Context, @@ -265,7 +262,8 @@ private async Task ProcessFile(HashSet offendingFiles, DocumentationFile Document = document, SourceFile = markdown, DefaultOutputFile = outputFile, - DocumentationSet = DocumentationSet + DocumentationSet = DocumentationSet, + NavigationItem = navigationItem }, ctx); } } @@ -354,14 +352,14 @@ private async Task GenerateDocumentationState(Cancel ctx) public async Task RenderLlmMarkdown(MarkdownFile markdown, Cancel ctx) { - await DocumentationSet.Tree.Resolve(ctx); - var document = await markdown.ParseFullAsync(ctx); + await DocumentationSet.ResolveDirectoryTree(ctx); + var document = await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); return LlmMarkdownExporter.ConvertToLlmMarkdown(document, DocumentationSet.Context); } public async Task RenderLayout(MarkdownFile markdown, Cancel ctx) { - await DocumentationSet.Tree.Resolve(ctx); + await DocumentationSet.ResolveDirectoryTree(ctx); return await HtmlWriter.RenderLayout(markdown, ctx); } } diff --git a/src/Elastic.Markdown/Elastic.Markdown.csproj b/src/Elastic.Markdown/Elastic.Markdown.csproj index 9d5529c8d..117d86c3f 100644 --- a/src/Elastic.Markdown/Elastic.Markdown.csproj +++ b/src/Elastic.Markdown/Elastic.Markdown.csproj @@ -18,6 +18,7 @@ + @@ -37,6 +38,7 @@ + diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs index c26f9f2da..ae453e9ef 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs @@ -164,13 +164,9 @@ private async Task PublishSynonymsAsync(string setName, CancellationToken ctx) var response = await _transport.PutAsync($"_synonyms/{setName}", PostData.String(json), ctx); if (!response.ApiCallDetails.HasSuccessfulStatusCode) - { _collector.EmitGlobalError($"Failed to publish synonym set '{setName}'. Reason: {response.ApiCallDetails.OriginalException?.Message ?? response.ToString()}"); - } else - { _logger.LogInformation("Successfully published synonym set '{SetName}'.", setName); - } } private async ValueTask CountAsync(string index, string body, Cancel ctx = default) @@ -386,7 +382,9 @@ private async ValueTask DoReindex(PostData request, string lexicalWriteAlias, st public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, Cancel ctx) { var file = fileContext.SourceFile; - var url = file.Url; + IPositionalNavigation navigation = fileContext.DocumentationSet; + var currentNavigation = navigation.GetCurrent(file); + var url = currentNavigation.Url; if (url is "/docs" or "/docs/404") { @@ -395,7 +393,6 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, return true; } - IPositionalNavigation navigation = fileContext.DocumentationSet; // Remove the first h1 because we already have the title // and we don't want it to appear in the body diff --git a/src/Elastic.Markdown/Exporters/ExporterExtensions.cs b/src/Elastic.Markdown/Exporters/ExporterExtensions.cs index 3f63c7281..cbaf0ddae 100644 --- a/src/Elastic.Markdown/Exporters/ExporterExtensions.cs +++ b/src/Elastic.Markdown/Exporters/ExporterExtensions.cs @@ -4,7 +4,6 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; -using Elastic.Documentation.Configuration.Assembler; using Elastic.Markdown.Exporters.Elasticsearch; using Microsoft.Extensions.Logging; diff --git a/src/Elastic.Markdown/Exporters/IMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/IMarkdownExporter.cs index cd9e3c125..f67cd528c 100644 --- a/src/Elastic.Markdown/Exporters/IMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/IMarkdownExporter.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Navigation; using Elastic.Markdown.IO; using Elastic.Markdown.Myst; using Markdig.Syntax; @@ -18,6 +19,7 @@ public record MarkdownExportFileContext public required MarkdownFile SourceFile { get; init; } public required IFileInfo DefaultOutputFile { get; init; } public required DocumentationSet DocumentationSet { get; init; } + public required INavigationItem NavigationItem { get; init; } } public interface IMarkdownExporter diff --git a/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs index 8feaff33c..c337f285b 100644 --- a/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs @@ -145,8 +145,7 @@ private string CreateLlmContentWithMetadata(MarkdownExportFileContext context, s _ = metadata.AppendLine($"description: {generateDescription}"); } - if (!string.IsNullOrEmpty(sourceFile.Url)) - _ = metadata.AppendLine($"url: {context.BuildContext.CanonicalBaseUrl?.Scheme}://{context.BuildContext.CanonicalBaseUrl?.Host}{sourceFile.Url}"); + _ = metadata.AppendLine($"url: {context.BuildContext.CanonicalBaseUrl?.Scheme}://{context.BuildContext.CanonicalBaseUrl?.Host}{context.NavigationItem.Url}"); var pageProducts = GetPageProducts(sourceFile.YamlFrontMatter?.Products); if (pageProducts.Count > 0) diff --git a/src/Elastic.Documentation.Configuration/Plugins/DetectionRules/DetectionRule.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs similarity index 98% rename from src/Elastic.Documentation.Configuration/Plugins/DetectionRules/DetectionRule.cs rename to src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs index 0064f22ef..6c0d08852 100644 --- a/src/Elastic.Documentation.Configuration/Plugins/DetectionRules/DetectionRule.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs @@ -6,7 +6,7 @@ using Tomlet; using Tomlet.Models; -namespace Elastic.Documentation.Configuration.Plugins.DetectionRules; +namespace Elastic.Markdown.Extensions.DetectionRules; public record DetectionRuleThreat { diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs index babc2258a..b724a9d2a 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs @@ -4,8 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; -using Elastic.Documentation.Configuration.Plugins.DetectionRules; -using Elastic.Documentation.Configuration.Plugins.DetectionRules.TableOfContents; +using Elastic.Documentation.Navigation; using Elastic.Markdown.IO; using Elastic.Markdown.Myst; using Markdig.Syntax; @@ -14,16 +13,12 @@ namespace Elastic.Markdown.Extensions.DetectionRules; public record DetectionRuleOverviewFile : MarkdownFile { - public DetectionRuleOverviewFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build, DocumentationSet set) - : base(sourceFile, rootPath, parser, build, set) + public DetectionRuleOverviewFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build) + : base(sourceFile, rootPath, parser, build) { } - public RuleReference[] Rules { get; set; } = []; - - private Dictionary Files { get; } = []; - - public void AddDetectionRuleFile(DetectionRuleFile df, RuleReference ruleReference) => Files[ruleReference.RelativePath] = df; + internal ILeafNavigationItem[] RuleNavigations { get; set; } = []; protected override Task GetMinimalParseDocumentAsync(Cancel ctx) { @@ -42,9 +37,10 @@ protected override Task GetParseDocumentAsync(Cancel ctx) private string GetMarkdown() { + var rules = RuleNavigations.Select(navigation => (Navigation: navigation, Model: (DetectionRuleFile)navigation.Model)).ToList(); var groupedRules = - Rules - .GroupBy(r => r.Rule.Domain ?? "Unspecified") + rules + .GroupBy(r => r.Model.Rule.Domain ?? "Unspecified") .OrderBy(g => g.Key) .ToArray(); // language=markdown @@ -66,12 +62,12 @@ private string GetMarkdown() ## {group.Key} """; - foreach (var r in group.OrderBy(r => r.Rule.Name)) + foreach (var (navigation, model) in group.OrderBy(r => r.Model.Rule.Name)) { - var url = Files[r.RelativePath].Url; + // TODO update this to use the new URL from navigation markdown += $""" -[{r.Rule.Name}](!{url})
    +[{model.Rule.Name}](!{navigation.Url})
    """; } @@ -86,7 +82,7 @@ private string GetMarkdown() public record DetectionRuleFile : MarkdownFile { - public DetectionRule? Rule { get; set; } + public DetectionRule Rule { get; } public override string LinkReferenceRelativePath { get; } @@ -96,12 +92,12 @@ public DetectionRuleFile( IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, - BuildContext build, - DocumentationSet set - ) : base(sourceFile, rootPath, parser, build, set) + BuildContext build + ) : base(sourceFile, rootPath, parser, build) { RuleSourceMarkdownPath = SourcePath(sourceFile, build); LinkReferenceRelativePath = Path.GetRelativePath(build.DocumentationSourceDirectory.FullName, RuleSourceMarkdownPath.FullName); + Rule = DetectionRule.From(sourceFile); } private static IFileInfo SourcePath(IFileInfo rulePath, BuildContext build) @@ -121,11 +117,9 @@ public static IFileInfo OutputPath(IFileInfo rulePath, BuildContext build) return rulePath.FileSystem.FileInfo.New(newPath); } - protected override string RelativePathUrl => RelativePath.AsSpan().TrimStart("../").ToString(); - protected override Task GetMinimalParseDocumentAsync(Cancel ctx) { - Title = Rule?.Name; + Title = Rule.Name; var markdown = GetMarkdown(); var document = MarkdownParser.MinimalParseStringAsync(markdown, RuleSourceMarkdownPath, null); return Task.FromResult(document); @@ -140,8 +134,6 @@ protected override Task GetParseDocumentAsync(Cancel ctx) private string GetMarkdown() { - if (Rule is null) - return $"# {Title}"; // language=markdown var markdown = $""" diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs index ee3257e75..e28ad59d9 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs @@ -4,10 +4,13 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; -using Elastic.Documentation.Configuration.Plugins.DetectionRules.TableOfContents; -using Elastic.Documentation.Configuration.TableOfContents; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Configuration.Toc.DetectionRules; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Isolated.Node; using Elastic.Markdown.Exporters; using Elastic.Markdown.IO; +using Elastic.Markdown.Myst; namespace Elastic.Markdown.Extensions.DetectionRules; @@ -17,61 +20,41 @@ public class DetectionRulesDocsBuilderExtension(BuildContext build) : IDocsBuild public IDocumentationFileExporter? FileExporter { get; } = new RuleDocumentationFileExporter(build.ReadFileSystem, build.WriteFileSystem); - private DetectionRuleOverviewFile? _overviewFile; - public void Visit(DocumentationFile file, ITocItem tocItem) - { - // TODO the parsing of rules should not happen at ITocItem reading time. - // ensure the file has an instance of the rule the reference parsed. - if (file is DetectionRuleFile df && tocItem is RuleReference r) - { - df.Rule = r.Rule; - _overviewFile?.AddDetectionRuleFile(df, r); - - } + public DocumentationFile? CreateDocumentationFile(IFileInfo file, MarkdownParser markdownParser) => + file.Extension != ".toml" ? null : new DetectionRuleFile(file, Build.DocumentationSourceDirectory, markdownParser, Build); - if (file is DetectionRuleOverviewFile of && tocItem is RuleOverviewReference or) - { - var rules = or.Children.OfType().ToArray(); - of.Rules = rules; - _overviewFile = of; - } - } + public MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, MarkdownParser markdownParser) => + file.Name != "index.md" ? null : new DetectionRuleOverviewFile(file, sourceDirectory, markdownParser, Build); - public DocumentationFile? CreateDocumentationFile(IFileInfo file, DocumentationSet documentationSet) + /// + public void VisitNavigation(INavigationItem navigation, IDocumentationFile model) { - if (file.Extension != ".toml") - return null; - - return new DetectionRuleFile(file, Build.DocumentationSourceDirectory, documentationSet.MarkdownParser, Build, documentationSet); + if (model is not DetectionRuleOverviewFile overview) + return; + if (navigation is not VirtualFileNavigation node) + return; + var detectionRuleNavigations = node.NavigationItems + .OfType>() + .Where(n => n.Model is DetectionRuleFile) + .ToArray(); + + overview.RuleNavigations = detectionRuleNavigations; } - public MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, DocumentationSet documentationSet) => - file.Name == "index.md" - ? new DetectionRuleOverviewFile(file, sourceDirectory, documentationSet.MarkdownParser, Build, documentationSet) - : null; - public bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, string slug, out DocumentationFile? documentationFile) { var tomlFile = $"../{slug}.toml"; - return documentationSet.FlatMappedFiles.TryGetValue(tomlFile, out documentationFile); + var filePath = new FilePath(tomlFile, Build.DocumentationSourceDirectory); + return documentationSet.Files.TryGetValue(filePath, out documentationFile); } - public IReadOnlyCollection ScanDocumentationFiles( - Func defaultFileHandling - ) + public IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles(Func defaultFileHandling) { - var rules = Build.Configuration.TableOfContents.OfType().First().Children.OfType().ToArray(); + var rules = Build.ConfigurationYaml.TableOfContents.OfType().First().Children.OfType().ToArray(); if (rules.Length == 0) return []; - var sourcePath = Path.GetFullPath(Path.Combine(Build.DocumentationSourceDirectory.FullName, rules[0].SourceDirectory)); - var sourceDirectory = Build.ReadFileSystem.DirectoryInfo.New(sourcePath); - return rules.Select(r => - { - var file = Build.ReadFileSystem.FileInfo.New(Path.Combine(sourceDirectory.FullName, r.RelativePath)); - return defaultFileHandling(file, sourceDirectory); - - }).ToArray(); + return rules.Select(r => (r.FileInfo, defaultFileHandling(r.FileInfo, r.FileInfo.Directory!))).ToArray(); } } diff --git a/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs index d5c4fd7d5..1dcba78d6 100644 --- a/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/IDocsBuilderExtension.cs @@ -3,9 +3,11 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using Elastic.Documentation.Configuration.TableOfContents; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Navigation; using Elastic.Markdown.Exporters; using Elastic.Markdown.IO; +using Elastic.Markdown.Myst; namespace Elastic.Markdown.Extensions; @@ -13,18 +15,17 @@ public interface IDocsBuilderExtension { IDocumentationFileExporter? FileExporter { get; } - /// Visit the and its equivalent - void Visit(DocumentationFile file, ITocItem tocItem); - /// Create an instance of if it matches the . /// Return `null` to let another extension handle this. - DocumentationFile? CreateDocumentationFile(IFileInfo file, DocumentationSet documentationSet); + DocumentationFile? CreateDocumentationFile(IFileInfo file, MarkdownParser markdownParser); /// Attempts to locate a documentation file by slug, used to locate the document for `docs-builder serve` command bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, string slug, out DocumentationFile? documentationFile); /// Allows the extension to discover more documentation files for - IReadOnlyCollection ScanDocumentationFiles(Func defaultFileHandling); + IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles(Func defaultFileHandling); + + MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, MarkdownParser markdownParser); - MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, DocumentationSet documentationSet); + void VisitNavigation(INavigationItem navigation, IDocumentationFile model); } diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 761e8b1ad..2e8431caa 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.Extensions.DetectionRules; @@ -25,7 +26,6 @@ public class HtmlWriter( IDescriptionGenerator descriptionGenerator, INavigationHtmlWriter? navigationHtmlWriter = null, ILegacyUrlMapper? legacyUrlMapper = null, - IPositionalNavigation? positionalNavigation = null, IVersionInferrerService? versionInferrerService = null ) : IMarkdownStringRenderer @@ -33,11 +33,11 @@ public class HtmlWriter( private DocumentationSet DocumentationSet { get; } = documentationSet; private INavigationHtmlWriter NavigationHtmlWriter { get; } = - navigationHtmlWriter ?? new IsolatedBuildNavigationHtmlWriter(documentationSet.Context, documentationSet.Tree); + navigationHtmlWriter ?? new IsolatedBuildNavigationHtmlWriter(documentationSet.Context, documentationSet.Navigation); private StaticFileContentHashProvider StaticFileContentHashProvider { get; } = new(new EmbeddedOrPhysicalFileProvider(documentationSet.Context)); private ILegacyUrlMapper LegacyUrlMapper { get; } = legacyUrlMapper ?? new NoopLegacyUrlMapper(); - private IPositionalNavigation PositionalNavigation { get; } = positionalNavigation ?? documentationSet; + private IPositionalNavigation PositionalNavigation { get; } = documentationSet; private IVersionInferrerService VersionInferrerService { get; } = versionInferrerService ?? new NoopVersionInferrer(); @@ -51,21 +51,21 @@ public string Render(string markdown, IFileInfo? source) public async Task RenderLayout(MarkdownFile markdown, Cancel ctx = default) { - var document = await markdown.ParseFullAsync(ctx); + var document = await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); return await RenderLayout(markdown, document, ctx); } private async Task RenderLayout(MarkdownFile markdown, MarkdownDocument document, Cancel ctx = default) { var html = MarkdownFile.CreateHtml(document); - await DocumentationSet.Tree.Resolve(ctx); + await DocumentationSet.ResolveDirectoryTree(ctx); + var navigationItem = DocumentationSet.FindNavigationByMarkdown(markdown); - var fullNavigationRenderResult = await NavigationHtmlWriter.RenderNavigation(markdown.NavigationRoot, INavigationHtmlWriter.AllLevels, ctx); - var miniNavigationRenderResult = await NavigationHtmlWriter.RenderNavigation(markdown.NavigationRoot, 1, ctx); + var root = navigationItem.NavigationRoot; var navigationHtmlRenderResult = DocumentationSet.Context.Configuration.Features.LazyLoadNavigation - ? miniNavigationRenderResult - : fullNavigationRenderResult; + ? await NavigationHtmlWriter.RenderNavigation(root, navigationItem, 1, ctx) + : await NavigationHtmlWriter.RenderNavigation(root, navigationItem, INavigationHtmlWriter.AllLevels, ctx); var current = PositionalNavigation.GetCurrent(markdown); var previous = PositionalNavigation.GetPrevious(markdown); @@ -84,26 +84,32 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc Uri? reportLinkParameter = null; if (DocumentationSet.Context.CanonicalBaseUrl is not null) - reportLinkParameter = new Uri(DocumentationSet.Context.CanonicalBaseUrl, Path.Combine(DocumentationSet.Context.UrlPathPrefix ?? string.Empty, markdown.Url)); + reportLinkParameter = new Uri(DocumentationSet.Context.CanonicalBaseUrl, Path.Combine(DocumentationSet.Context.UrlPathPrefix ?? string.Empty, current.Url)); var reportUrl = $"https://github.com/elastic/docs-content/issues/new?template=issue-report.yaml&link={reportLinkParameter}&labels=source:web"; - var siteName = DocumentationSet.Tree.Index.Title ?? "Elastic Documentation"; + var siteName = DocumentationSet.Navigation.NavigationTitle; var legacyPages = LegacyUrlMapper.MapLegacyUrl(markdown.YamlFrontMatter?.MappedPages); var pageProducts = GetPageProducts(markdown.YamlFrontMatter?.Products); string? allVersionsUrl = null; - if (PositionalNavigation.MarkdownNavigationLookup.TryGetValue("docs-content://versions.md", out var item)) - allVersionsUrl = item.Url; + // TODO exposese allversions again + //if (PositionalNavigation.MarkdownNavigationLookup.TryGetValue("docs-content://versions.md", out var item)) + // allVersionsUrl = item.Url; + var navigationFileName = $"{navigationHtmlRenderResult.Id}.nav.html"; + if (DocumentationSet.Configuration.Features.LazyLoadNavigation) + { + var fullNavigationRenderResult = await NavigationHtmlWriter.RenderNavigation(root, navigationItem, INavigationHtmlWriter.AllLevels, ctx); + navigationFileName = $"{fullNavigationRenderResult.Id}.nav.html"; - var navigationFileName = $"{fullNavigationRenderResult.Id}.nav.html"; + _ = DocumentationSet.NavigationRenderResults.TryAdd( + fullNavigationRenderResult.Id, + fullNavigationRenderResult + ); - _ = DocumentationSet.NavigationRenderResults.TryAdd( - fullNavigationRenderResult.Id, - fullNavigationRenderResult - ); + } var pageVersioning = VersionInferrerService.InferVersion(DocumentationSet.Context.Git.RepositoryName, legacyPages); @@ -135,7 +141,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc UrlPathPrefix = markdown.UrlPathPrefix, AppliesTo = markdown.YamlFrontMatter?.AppliesTo, GithubEditUrl = editUrl, - MarkdownUrl = markdown.Url.TrimEnd('/') + ".md", + MarkdownUrl = current.Url.TrimEnd('/') + ".md", AllowIndexing = DocumentationSet.Context.AllowIndexing && (markdown.CrossLink.Equals("docs-content://index.md", StringComparison.OrdinalIgnoreCase) || markdown is DetectionRuleFile || !current.Hidden), CanonicalBaseUrl = DocumentationSet.Context.CanonicalBaseUrl, GoogleTagManager = DocumentationSet.Context.GoogleTagManager, @@ -154,7 +160,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc return new RenderResult { Html = await slice.RenderAsync(cancellationToken: ctx), - FullNavigationPartialHtml = fullNavigationRenderResult.Html, + FullNavigationPartialHtml = navigationHtmlRenderResult.Html, NavigationFileName = navigationFileName }; @@ -207,7 +213,7 @@ public async Task WriteAsync(IDirectoryInfo outBaseDir, IFileI : Path.Combine(dir, "index.html"); } - var document = await markdown.ParseFullAsync(ctx); + var document = await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx); var rendered = await RenderLayout(markdown, document, ctx); collector?.Collect(markdown, document, rendered.Html); diff --git a/src/Elastic.Markdown/IO/DocumentationFile.cs b/src/Elastic.Markdown/IO/DocumentationFile.cs index a4d53b89e..7b0ecd50a 100644 --- a/src/Elastic.Markdown/IO/DocumentationFile.cs +++ b/src/Elastic.Markdown/IO/DocumentationFile.cs @@ -1,7 +1,10 @@ // 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.Collections.Frozen; using System.IO.Abstractions; +using Elastic.Documentation.Diagnostics; using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.FrontMatter; @@ -44,7 +47,8 @@ public record SnippetFile(IFileInfo SourceFile, IDirectoryInfo RootPath, string private bool _parsed; public SnippetAnchors? GetAnchors( - DocumentationSet set, + IDiagnosticsCollector collector, + Func documentationFileLookup, MarkdownParser parser, YamlFrontMatter? frontMatter ) @@ -58,7 +62,7 @@ public record SnippetFile(IFileInfo SourceFile, IDirectoryInfo RootPath, string } var document = parser.MinimalParseAsync(SourceFile, default).GetAwaiter().GetResult(); - var toc = MarkdownFile.GetAnchors(set, parser, frontMatter, document, new Dictionary(), out var anchors); + var toc = MarkdownFile.GetAnchors(collector, documentationFileLookup, parser, frontMatter, document, new Dictionary(), out var anchors); Anchors = new SnippetAnchors(anchors, toc); _parsed = true; return Anchors; diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 303f637fa..6ae6cefcc 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -5,101 +5,25 @@ using System.Collections.Concurrent; using System.Collections.Frozen; using System.IO.Abstractions; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; -using Elastic.Documentation.Configuration.TableOfContents; using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Isolated.Leaf; +using Elastic.Documentation.Navigation.Isolated.Node; using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.Extensions; using Elastic.Markdown.Extensions.DetectionRules; -using Elastic.Markdown.IO.Navigation; using Elastic.Markdown.Myst; using Microsoft.Extensions.Logging; namespace Elastic.Markdown.IO; -public interface INavigationLookups -{ - FrozenDictionary FlatMappedFiles { get; } - IReadOnlyCollection TableOfContents { get; } - IReadOnlyCollection EnabledExtensions { get; } - FrozenDictionary FilesGroupedByFolder { get; } - ICrossLinkResolver CrossLinkResolver { get; } -} - -public interface IPositionalNavigation -{ - FrozenDictionary MarkdownNavigationLookup { get; } - FrozenDictionary NavigationIndexedByOrder { get; } - - INavigationItem? GetPrevious(MarkdownFile current) - { - if (!MarkdownNavigationLookup.TryGetValue(current.CrossLink, out var currentNavigation)) - return null; - var index = currentNavigation.NavigationIndex; - do - { - var previous = NavigationIndexedByOrder.GetValueOrDefault(index - 1); - if (previous is not null && !previous.Hidden) - return previous; - index--; - } while (index > 0); - - return null; - } - - INavigationItem? GetNext(MarkdownFile current) - { - if (!MarkdownNavigationLookup.TryGetValue(current.CrossLink, out var currentNavigation)) - return null; - var index = currentNavigation.NavigationIndex; - do - { - var next = NavigationIndexedByOrder.GetValueOrDefault(index + 1); - if (next is not null && !next.Hidden && next.Url != currentNavigation.Url) - return next; - index++; - } while (index <= NavigationIndexedByOrder.Count - 1); - - return null; - } - - INavigationItem GetCurrent(MarkdownFile file) => - MarkdownNavigationLookup.GetValueOrDefault(file.CrossLink) ?? throw new InvalidOperationException($"Could not find {file.CrossLink} in navigation"); - - INavigationItem[] GetParents(INavigationItem current) - { - var parents = new List(); - var parent = current.Parent; - do - { - if (parent is null) - continue; - if (parents.All(i => i.Url != parent.Url)) - parents.Add(parent); - - parent = parent.Parent; - } while (parent != null); - - return [.. parents]; - } - INavigationItem[] GetParentsOfMarkdownFile(MarkdownFile file) => - MarkdownNavigationLookup.TryGetValue(file.CrossLink, out var navigationItem) ? GetParents(navigationItem) : []; -} - -public record NavigationLookups : INavigationLookups -{ - public required FrozenDictionary FlatMappedFiles { get; init; } - public required IReadOnlyCollection TableOfContents { get; init; } - public required IReadOnlyCollection EnabledExtensions { get; init; } - public required FrozenDictionary FilesGroupedByFolder { get; init; } - public required ICrossLinkResolver CrossLinkResolver { get; init; } -} - -public class DocumentationSet : INavigationLookups, IPositionalNavigation +public class DocumentationSet : IPositionalNavigation { private readonly ILogger _logger; public BuildContext Context { get; } @@ -118,19 +42,9 @@ public class DocumentationSet : INavigationLookups, IPositionalNavigation public ICrossLinkResolver CrossLinkResolver { get; } - public TableOfContentsTree Tree { get; } - - public Uri Source { get; } - - public IReadOnlyCollection Files { get; } - - public FrozenDictionary FilesGroupedByFolder { get; } - - public FrozenDictionary FlatMappedFiles { get; } + public FrozenDictionary Files { get; } - IReadOnlyCollection INavigationLookups.TableOfContents => Configuration.TableOfContents; - - public FrozenDictionary MarkdownNavigationLookup { get; } + public ConditionalWeakTable MarkdownNavigationLookup { get; } public IReadOnlyCollection EnabledExtensions { get; } @@ -139,178 +53,112 @@ public class DocumentationSet : INavigationLookups, IPositionalNavigation public DocumentationSet( BuildContext context, ILoggerFactory logFactory, - ICrossLinkResolver linkResolver, - TableOfContentsTreeCollector? treeCollector = null + ICrossLinkResolver linkResolver ) { _logger = logFactory.CreateLogger(); Context = context; - Source = ContentSourceMoniker.Create(context.Git.RepositoryName, null); SourceDirectory = context.DocumentationSourceDirectory; OutputDirectory = context.OutputDirectory; CrossLinkResolver = linkResolver; Configuration = context.Configuration; EnabledExtensions = InstantiateExtensions(); - treeCollector ??= new TableOfContentsTreeCollector(); var resolver = new ParserResolvers { CrossLinkResolver = CrossLinkResolver, - DocumentationFileLookup = DocumentationFileLookup + TryFindDocument = TryFindDocument, + TryFindDocumentByRelativePath = TryFindDocumentByRelativePath, + PositionalNavigation = this }; MarkdownParser = new MarkdownParser(context, resolver); + var fileFactory = new MarkdownFileFactory(context, MarkdownParser, EnabledExtensions); + Navigation = new DocumentationSetNavigation(context.ConfigurationYaml, context, fileFactory, null, null, context.UrlPathPrefix, CrossLinkResolver); + VisitNavigation(Navigation); + Name = Context.Git != GitCheckoutInformation.Unavailable ? Context.Git.RepositoryName : Context.DocumentationCheckoutDirectory?.Name ?? $"unknown-{Context.DocumentationSourceDirectory.Name}"; OutputStateFile = OutputDirectory.FileSystem.FileInfo.New(Path.Combine(OutputDirectory.FullName, ".doc.state")); LinkReferenceFile = OutputDirectory.FileSystem.FileInfo.New(Path.Combine(OutputDirectory.FullName, "links.json")); - var files = ScanDocumentationFiles(context, SourceDirectory); - var additionalSources = EnabledExtensions - .SelectMany(extension => extension.ScanDocumentationFiles(DefaultFileHandling)) - .ToArray(); - - Files = files.Concat(additionalSources).Where(f => f is not ExcludedFile).ToArray(); - - LastWrite = Files.Max(f => f.SourceFile.LastWriteTimeUtc); - - FlatMappedFiles = Files.ToDictionary(file => file.RelativePath, file => file).ToFrozenDictionary(); - - FilesGroupedByFolder = Files - .GroupBy(file => file.RelativeFolder) - .ToDictionary(g => g.Key, g => g.ToArray()) - .ToFrozenDictionary(); - - var fileIndex = 0; - var lookups = new NavigationLookups - { - FlatMappedFiles = FlatMappedFiles, - TableOfContents = Configuration.TableOfContents, - EnabledExtensions = EnabledExtensions, - FilesGroupedByFolder = FilesGroupedByFolder, - CrossLinkResolver = CrossLinkResolver - }; - - Tree = new TableOfContentsTree(Source, Context, lookups, treeCollector, ref fileIndex); + Files = fileFactory.Files; + var files = Files.Values.ToArray(); + LastWrite = files.Max(f => f.SourceFile.LastWriteTimeUtc); - var navigationIndex = 0; - UpdateNavigationIndex(Tree.NavigationItems, ref navigationIndex); - var markdownFiles = Files.OfType().ToArray(); + var markdownFiles = files.OfType().ToArray(); + MarkdownFiles = markdownFiles.ToFrozenSet(); - var excludedChildren = markdownFiles.Where(f => !f.PartOfNavigation).ToArray(); - foreach (var excludedChild in excludedChildren) - Context.EmitError(Context.ConfigurationPath, $"{excludedChild.RelativePath} is unreachable in the TOC because one of its parents matches exclusion glob"); - - MarkdownFiles = markdownFiles.Where(f => f.PartOfNavigation).ToFrozenSet(); - NavigationIndexedByOrder = CreateNavigationLookup(Tree) + MarkdownNavigationLookup = []; + var navigationFlatList = CreateNavigationLookup(Navigation); + NavigationIndexedByOrder = navigationFlatList + .DistinctBy(n => n.NavigationIndex) .ToDictionary(n => n.NavigationIndex, n => n) .ToFrozenDictionary(); - MarkdownNavigationLookup = Tree.NavigationItems - .SelectMany(Pairs) - .Concat(Pairs(Tree)) - .DistinctBy(kv => kv.Item1) - .ToDictionary(kv => kv.Item1, kv => kv.Item2) + // Build cross-link dictionary including both: + // 1. Direct leaf items (files without children) + // 2. Index property of node items (files with children) + var leafItems = navigationFlatList.OfType>(); + var nodeIndexes = navigationFlatList + .OfType>() + .Select(node => node.Index); + + NavigationIndexedByCrossLink = leafItems + .Concat(nodeIndexes) + .DistinctBy(n => n.Model.CrossLink) + .ToDictionary(n => n.Model.CrossLink, n => n) .ToFrozenDictionary(); ValidateRedirectsExists(); } - private void UpdateNavigationIndex(IReadOnlyCollection navigationItems, ref int navigationIndex) - { - foreach (var item in navigationItems) - { - switch (item) - { - case FileNavigationItem fileNavigationItem: - var fileIndex = Interlocked.Increment(ref navigationIndex); - fileNavigationItem.NavigationIndex = fileIndex; - break; - case CrossLinkNavigationItem crossLinkNavigationItem: - var crossLinkIndex = Interlocked.Increment(ref navigationIndex); - crossLinkNavigationItem.NavigationIndex = crossLinkIndex; - break; - case DocumentationGroup documentationGroup: - var groupIndex = Interlocked.Increment(ref navigationIndex); - documentationGroup.NavigationIndex = groupIndex; - UpdateNavigationIndex(documentationGroup.NavigationItems, ref navigationIndex); - break; - default: - Context.EmitError(Context.ConfigurationPath, $"{nameof(DocumentationSet)}.{nameof(UpdateNavigationIndex)}: Unhandled navigation item type: {item.GetType()}"); - break; - } - } - } + public FrozenDictionary> NavigationIndexedByCrossLink { get; } + + public DocumentationSetNavigation Navigation { get; } public FrozenDictionary NavigationIndexedByOrder { get; } - private static IReadOnlyCollection CreateNavigationLookup(INavigationItem item) + private void VisitNavigation(INavigationItem item) { - if (item is ILeafNavigationItem leaf) - return [leaf]; - - if (item is CrossLinkNavigationItem crossLink) - return [crossLink]; - - if (item is INodeNavigationItem node) + switch (item) { - var items = node.NavigationItems.SelectMany(CreateNavigationLookup); - return items.Concat([node]).ToArray(); + case ILeafNavigationItem markdownLeaf: + foreach (var extension in EnabledExtensions) + extension.VisitNavigation(item, markdownLeaf.Model); + break; + case INodeNavigationItem node: + foreach (var extension in EnabledExtensions) + extension.VisitNavigation(node, node.Index.Model); + foreach (var child in node.NavigationItems) + VisitNavigation(child); + break; } - - return []; } - public static (string, INavigationItem)[] Pairs(INavigationItem item) + private IReadOnlyCollection CreateNavigationLookup(INavigationItem item) { - if (item is FileNavigationItem f) - return [(f.Model.CrossLink, item)]; - if (item is CrossLinkNavigationItem cl) - return [(cl.Url, item)]; // Use the URL as the key for cross-links - if (item is DocumentationGroup g) + switch (item) { - var index = new List<(string, INavigationItem)> - { - (g.Index.CrossLink, g) - }; - - return index.Concat(g.NavigationItems.SelectMany(Pairs).ToArray()) - .DistinctBy(kv => kv.Item1) - .ToArray(); + case ILeafNavigationItem markdownLeaf: + var added = MarkdownNavigationLookup.TryAdd(markdownLeaf.Model, markdownLeaf); + if (!added) + Context.EmitWarning(Configuration.SourceFile, $"Duplicate navigation item {markdownLeaf.Model.CrossLink}"); + return [markdownLeaf]; + case ILeafNavigationItem crossLink: + return [crossLink]; + case ILeafNavigationItem leaf: + throw new Exception($"Should not be possible to have a leaf navigation item that is not a markdown file: {leaf.Model.GetType().FullName}"); + case INodeNavigationItem node: + _ = MarkdownNavigationLookup.TryAdd(node.Index.Model, node); + var nodeItems = node.NavigationItems.SelectMany(CreateNavigationLookup); + return nodeItems.Concat([node, node.Index]).ToArray(); + case INodeNavigationItem node: + throw new Exception($"Should not be possible to have a leaf navigation item that is not a markdown file: {node.GetType().FullName}"); + default: + return []; } - - return []; - } - - private DocumentationFile[] ScanDocumentationFiles(BuildContext build, IDirectoryInfo sourceDirectory) => - [.. build.ReadFileSystem.Directory - .EnumerateFiles(sourceDirectory.FullName, "*.*", SearchOption.AllDirectories) - .Select(f => build.ReadFileSystem.FileInfo.New(f)) - .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden) && !f.Attributes.HasFlag(FileAttributes.System)) - .Where(f => !f.Directory!.Attributes.HasFlag(FileAttributes.Hidden) && !f.Directory!.Attributes.HasFlag(FileAttributes.System)) - // skip hidden folders - .Where(f => !Path.GetRelativePath(sourceDirectory.FullName, f.FullName).StartsWith('.')) - .Select(file => file.Extension switch - { - ".jpg" => new ImageFile(file, SourceDirectory, build.Git.RepositoryName, "image/jpeg"), - ".jpeg" => new ImageFile(file, SourceDirectory, build.Git.RepositoryName, "image/jpeg"), - ".gif" => new ImageFile(file, SourceDirectory, build.Git.RepositoryName, "image/gif"), - ".svg" => new ImageFile(file, SourceDirectory, build.Git.RepositoryName, "image/svg+xml"), - ".png" => new ImageFile(file, SourceDirectory, build.Git.RepositoryName), - ".md" => CreateMarkDownFile(file, build), - _ => DefaultFileHandling(file, sourceDirectory) - })]; - - private DocumentationFile DefaultFileHandling(IFileInfo file, IDirectoryInfo sourceDirectory) - { - foreach (var extension in EnabledExtensions) - { - var documentationFile = extension.CreateDocumentationFile(file, this); - if (documentationFile is not null) - return documentationFile; - } - return new ExcludedFile(file, sourceDirectory, Context.Git.RepositoryName); } private void ValidateRedirectsExists() @@ -344,7 +192,8 @@ void ValidateExists(string from, string to, IReadOnlyDictionary if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) to = to.Replace('/', Path.DirectorySeparatorChar); - if (!FlatMappedFiles.TryGetValue(to, out var file)) + var fp = new FilePath(to, SourceDirectory); + if (!Files.TryGetValue(fp, out var file)) { Context.EmitError(Configuration.SourceFile, $"Redirect {from} points to {to} which does not exist"); return; @@ -371,61 +220,53 @@ void ValidateExists(string from, string to, IReadOnlyDictionary public FrozenSet MarkdownFiles { get; } public string FirstInterestingUrl => - NavigationIndexedByOrder.Values.OfType().First().Url; + NavigationIndexedByOrder.Values.OfType>().First().Url; - public DocumentationFile? DocumentationFileLookup(IFileInfo sourceFile) + public DocumentationFile? TryFindDocument(IFileInfo sourceFile) { var relativePath = Path.GetRelativePath(SourceDirectory.FullName, sourceFile.FullName); - return FlatMappedFiles.GetValueOrDefault(relativePath); + return TryFindDocumentByRelativePath(relativePath); } - - public async Task ResolveDirectoryTree(Cancel ctx) => await Tree.Resolve(ctx); - - private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext context) + public DocumentationFile? TryFindDocumentByRelativePath(string relativePath) { - var relativePath = Path.GetRelativePath(SourceDirectory.FullName, file.FullName); - if (Configuration.Exclude.Any(g => g.IsMatch(relativePath))) - return new ExcludedFile(file, SourceDirectory, context.Git.RepositoryName); - - if (relativePath.Contains("_snippets")) - return new SnippetFile(file, SourceDirectory, context.Git.RepositoryName); - - // we ignore files in folders that start with an underscore - var folder = Path.GetDirectoryName(relativePath); - if (folder is not null && (folder.Contains($"{Path.DirectorySeparatorChar}_", StringComparison.Ordinal) || folder.StartsWith('_'))) - return new ExcludedFile(file, SourceDirectory, context.Git.RepositoryName); + var fp = new FilePath(relativePath, SourceDirectory); + return Files.GetValueOrDefault(fp); + } - if (Configuration.Files.Contains(relativePath)) - return ExtensionOrDefaultMarkdown(); + public INavigationItem FindNavigationByMarkdown(MarkdownFile markdown) + { + if (MarkdownNavigationLookup.TryGetValue(markdown, out var navigation)) + return navigation; + throw new Exception($"Could not find navigation item for {markdown.CrossLink}"); + } - if (Configuration.Globs.Any(g => g.IsMatch(relativePath))) - return ExtensionOrDefaultMarkdown(); + private bool _resolved; + public async Task ResolveDirectoryTree(Cancel ctx) + { + if (_resolved) + return; - context.EmitError(Configuration.SourceFile, $"Not linked in toc: {relativePath}"); - return new ExcludedFile(file, SourceDirectory, context.Git.RepositoryName); + await Parallel.ForEachAsync(MarkdownFiles, ctx, async (file, token) => await file.MinimalParseAsync(TryFindDocumentByRelativePath, token)); - MarkdownFile ExtensionOrDefaultMarkdown() - { - foreach (var extension in EnabledExtensions) - { - var documentationFile = extension.CreateMarkdownFile(file, SourceDirectory, this); - if (documentationFile is not null) - return documentationFile; - } - return new MarkdownFile(file, SourceDirectory, MarkdownParser, context, this); - } + _resolved = true; } public RepositoryLinks CreateLinkReference() { var redirects = Configuration.Redirects; - var crossLinks = Context.Collector.CrossLinks.ToHashSet().ToArray(); - var markdownInNavigation = NavigationIndexedByOrder.Values - .OfType() + var crossLinks = Context.Collector.CrossLinks.ToHashSet().OrderBy(l => l).ToArray(); + + var leafs = NavigationIndexedByOrder.Values + .OfType>().ToArray(); + var nodes = NavigationIndexedByOrder.Values + .OfType>() + .ToArray(); + + var markdownInNavigation = + leafs .Select(m => (Markdown: m.Model, Navigation: (INavigationItem)m)) - .Concat(NavigationIndexedByOrder.Values - .OfType() - .Select(g => (Markdown: g.Index, Navigation: (INavigationItem)g)) + .Concat(nodes + .Select(g => (Markdown: (MarkdownFile)g.Index.Model, Navigation: (INavigationItem)g)) ) .ToList(); @@ -438,6 +279,7 @@ public RepositoryLinks CreateLinkReference() return (Path: path, tuple.Markdown, tuple.Navigation); }) .DistinctBy(tuple => tuple.Path) + .OrderBy(tuple => tuple.Path) .ToDictionary( tuple => tuple.Path, tuple => diff --git a/src/Elastic.Markdown/IO/IPositionalNavigation.cs b/src/Elastic.Markdown/IO/IPositionalNavigation.cs new file mode 100644 index 000000000..c1a9abe68 --- /dev/null +++ b/src/Elastic.Markdown/IO/IPositionalNavigation.cs @@ -0,0 +1,72 @@ +// 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.Collections.Frozen; +using System.Runtime.CompilerServices; +using Elastic.Documentation.Navigation; + +namespace Elastic.Markdown.IO; + +public interface IPositionalNavigation +{ + ConditionalWeakTable MarkdownNavigationLookup { get; } + FrozenDictionary NavigationIndexedByOrder { get; } + FrozenDictionary> NavigationIndexedByCrossLink { get; } + + INavigationItem? GetPrevious(MarkdownFile current) + { + if (!MarkdownNavigationLookup.TryGetValue(current, out var currentNavigation)) + return null; + var index = currentNavigation.NavigationIndex; + do + { + var previous = NavigationIndexedByOrder.GetValueOrDefault(index - 1); + if (previous is not null && !previous.Hidden && previous.Url != currentNavigation.Url) + return previous; + index--; + } while (index > 0); + + return null; + } + + INavigationItem? GetNext(MarkdownFile current) + { + if (!MarkdownNavigationLookup.TryGetValue(current, out var currentNavigation)) + return null; + var index = currentNavigation.NavigationIndex; + do + { + var next = NavigationIndexedByOrder.GetValueOrDefault(index + 1); + if (next is not null && !next.Hidden && next.Url != currentNavigation.Url) + return next; + index++; + } while (index <= NavigationIndexedByOrder.Count - 1); + + return null; + } + + INavigationItem GetCurrent(MarkdownFile file) => + MarkdownNavigationLookup.TryGetValue(file, out var navigation) + ? navigation : throw new InvalidOperationException($"Could not find {file.RelativePath} in navigation"); + + INavigationItem[] GetParents(INavigationItem current) + { + var parents = new List(); + var parent = current.Parent; + do + { + if (parent is null) + continue; + if (parents.All(i => i.Url != parent.Url)) + parents.Add(parent); + + parent = parent.Parent; + } while (parent != null); + + return [.. parents]; + } + + INavigationItem[] GetParentsOfMarkdownFile(MarkdownFile file) => + MarkdownNavigationLookup.TryGetValue(file, out var navigation) ? GetParents(navigation) : []; +} diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index d9dfb8358..f1323ceef 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -3,12 +3,10 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using System.Runtime.InteropServices; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Diagnostics; -using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Navigation; -using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.Helpers; using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.Directives; @@ -22,12 +20,10 @@ namespace Elastic.Markdown.IO; -public record MarkdownFile : DocumentationFile, ITableOfContentsScope, INavigationModel +public record MarkdownFile : DocumentationFile, ITableOfContentsScope, IDocumentationFile { private string? _navigationTitle; - private readonly DocumentationSet _set; - private readonly IFileInfo _configurationFile; private readonly IReadOnlyDictionary _globalSubstitutions; @@ -36,8 +32,7 @@ public MarkdownFile( IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, - BuildContext build, - DocumentationSet set + BuildContext build ) : base(sourceFile, rootPath, build.Git.RepositoryName) { @@ -49,20 +44,17 @@ DocumentationSet set Collector = build.Collector; _configurationFile = build.Configuration.SourceFile; _globalSubstitutions = build.Configuration.Substitutions; - _set = set; //may be updated by DocumentationGroup.ProcessTocItems //todo refactor mutability of MarkdownFile as a whole ScopeDirectory = build.Configuration.ScopeDirectory; + Products = build.ProductsConfiguration; - NavigationRoot = set.Tree; } - public bool PartOfNavigation { get; set; } + public ProductsConfiguration Products { get; } public IDirectoryInfo ScopeDirectory { get; set; } - public IRootNavigationItem NavigationRoot { get; set; } - private IDiagnosticsCollector Collector { get; } public string? UrlPathPrefix { get; } @@ -97,45 +89,6 @@ public string NavigationTitle public string FilePath { get; } public string FileName { get; } - protected virtual string RelativePathUrl => RelativePath; - - private string DefaultUrlPathSuffix - { - get - { - var relativePath = RelativePathUrl; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - relativePath = relativePath.Replace('\\', '/'); - return Path.GetFileName(relativePath) == "index.md" - ? $"/{relativePath.Remove(relativePath.LastIndexOf("index.md", StringComparison.Ordinal), "index.md".Length)}" - : $"/{relativePath.Remove(relativePath.LastIndexOf(SourceFile.Extension, StringComparison.Ordinal), SourceFile.Extension.Length)}"; - } - } - - private string DefaultUrlPath => $"{UrlPathPrefix}{DefaultUrlPathSuffix}"; - - private string? _url; - public string Url - { - get - { - if (_url is not null) - return _url; - if (_set.CrossLinkResolver.UriResolver is IsolatedBuildEnvironmentUriResolver) - { - _url = DefaultUrlPath; - return _url; - } - var crossLink = new Uri(CrossLink); - var uri = _set.CrossLinkResolver.UriResolver.Resolve(crossLink, DefaultUrlPathSuffix); - _url = uri.AbsolutePath; - return _url; - - } - } - - //public int NavigationIndex { get; set; } = -1; - private bool _instructionsParsed; private string? _title; @@ -164,18 +117,18 @@ protected virtual async Task GetMinimalParseDocumentAsync(Canc protected virtual async Task GetParseDocumentAsync(Cancel ctx) => await MarkdownParser.ParseAsync(SourceFile, YamlFrontMatter, ctx); - public async Task MinimalParseAsync(Cancel ctx) + public async Task MinimalParseAsync(Func documentationFileLookup, Cancel ctx) { var document = await GetMinimalParseDocumentAsync(ctx); - ReadDocumentInstructions(document); + ReadDocumentInstructions(document, documentationFileLookup); ValidateAnchorRemapping(); return document; } - public async Task ParseFullAsync(Cancel ctx) + public async Task ParseFullAsync(Func documentationFileLookup, Cancel ctx) { if (!_instructionsParsed) - _ = await MinimalParseAsync(ctx); + _ = await MinimalParseAsync(documentationFileLookup, ctx); var document = await GetParseDocumentAsync(ctx); return document; @@ -194,7 +147,7 @@ private IReadOnlyDictionary GetSubstitutions() return allProperties; } - protected void ReadDocumentInstructions(MarkdownDocument document) + protected void ReadDocumentInstructions(MarkdownDocument document, Func documentationFileLookup) { Title ??= document .FirstOrDefault(block => block is HeadingBlock { Level: 1 })? @@ -221,7 +174,7 @@ protected void ReadDocumentInstructions(MarkdownDocument document) else if (Title.AsSpan().ReplaceSubstitutions(subs, Collector, out var replacement)) Title = replacement; - var toc = GetAnchors(_set, MarkdownParser, YamlFrontMatter, document, subs, out var anchors); + var toc = GetAnchors(Collector, documentationFileLookup, MarkdownParser, YamlFrontMatter, document, subs, out var anchors); _pageTableOfContent.Clear(); foreach (var t in toc) @@ -235,7 +188,8 @@ protected void ReadDocumentInstructions(MarkdownDocument document) } public static List GetAnchors( - DocumentationSet set, + IDiagnosticsCollector collector, + Func documentationFileLookup, MarkdownParser parser, YamlFrontMatter? frontMatter, MarkdownDocument document, @@ -248,12 +202,13 @@ public static List GetAnchors( .Select(i => { var relativePath = i.IncludePathRelativeToSource; - if (relativePath is null - || !set.FlatMappedFiles.TryGetValue(relativePath, out var file) - || file is not SnippetFile snippet) + if (relativePath is null) + return null; + var doc = documentationFileLookup(relativePath); + if (doc is not SnippetFile snippet) return null; - var anchors = snippet.GetAnchors(set, parser, frontMatter); + var anchors = snippet.GetAnchors(collector, documentationFileLookup, parser, frontMatter); return new { Block = i, Anchors = anchors }; }) .Where(i => i is not null) @@ -295,7 +250,7 @@ public static List GetAnchors( { var processedTitle = step.Title; // Apply substitutions to step titles - if (subs.Count > 0 && processedTitle.AsSpan().ReplaceSubstitutions(subs, set.Context.Collector, out var replacement)) + if (subs.Count > 0 && processedTitle.AsSpan().ReplaceSubstitutions(subs, collector, out var replacement)) processedTitle = replacement; return new @@ -317,7 +272,7 @@ public static List GetAnchors( .Select(item => item.TocItem) .Select(toc => subs.Count == 0 ? toc - : toc.Heading.AsSpan().ReplaceSubstitutions(subs, set.Context.Collector, out var r) + : toc.Heading.AsSpan().ReplaceSubstitutions(subs, collector, out var r) ? toc with { Heading = r } : toc) .ToList(); @@ -369,9 +324,7 @@ private YamlFrontMatter ProcessYamlFrontMatter(MarkdownDocument document) foreach (var url in fm.MappedPages) { if (!string.IsNullOrEmpty(url) && (!url.StartsWith("https://www.elastic.co/guide", StringComparison.OrdinalIgnoreCase) || !Uri.IsWellFormedUriString(url, UriKind.Absolute))) - { Collector.EmitError(FilePath, $"Invalid mapped_pages URL: \"{url}\". All mapped_pages URLs must start with \"https://www.elastic.co/guide\". Please update the URL to reference content under the Elastic documentation guide."); - } } } @@ -395,7 +348,7 @@ private YamlFrontMatter ReadYamlFrontMatter(string raw) { try { - return YamlSerialization.Deserialize(raw, _set.Context.ProductsConfiguration); + return YamlSerialization.Deserialize(raw, Products); } catch (InvalidProductException e) { diff --git a/src/Elastic.Markdown/IO/MarkdownFileFactory.cs b/src/Elastic.Markdown/IO/MarkdownFileFactory.cs new file mode 100644 index 000000000..a1297ff99 --- /dev/null +++ b/src/Elastic.Markdown/IO/MarkdownFileFactory.cs @@ -0,0 +1,146 @@ +// 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.Collections.Frozen; +using System.Diagnostics; +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Markdown.Extensions; +using Elastic.Markdown.Myst; +using Generator.Equals; + +namespace Elastic.Markdown.IO; + +[Equatable] +[DebuggerDisplay("{RelativePath,nq}")] +public partial record FilePath +{ + public FilePath(IFileInfo fileInfo, IDirectoryInfo sourceDirectory) + { + FileInfo = fileInfo; + RelativePath = Path.GetRelativePath(sourceDirectory.FullName, fileInfo.FullName); + } + + public FilePath(string relativePath, IDirectoryInfo sourceDirectory) + { + FileInfo = sourceDirectory.FileSystem.NewFileInfo(sourceDirectory.FullName, relativePath); + RelativePath = relativePath; + } + + [StringEquality(StringComparison.OrdinalIgnoreCase)] + public string RelativePath { get; } + + [IgnoreEquality] + public IFileInfo FileInfo { get; } +} + +public class MarkdownFileFactory : IDocumentationFileFactory +{ + private readonly BuildContext _context; + private readonly MarkdownParser _markdownParser; + + public MarkdownFileFactory(BuildContext context, MarkdownParser markdownParser, IReadOnlyCollection enabledExtensions) + { + _context = context; + _markdownParser = markdownParser; + EnabledExtensions = enabledExtensions; + + var files = ScanDocumentationFiles(context, context.DocumentationSourceDirectory); + var additionalSources = enabledExtensions + .SelectMany(extension => extension.ScanDocumentationFiles(DefaultFileHandling)) + .ToArray(); + + Files = files.Concat(additionalSources) + .Where(t => t.Item2 is not ExcludedFile) + .ToDictionary(kv => new FilePath(kv.Item1, context.DocumentationSourceDirectory), kv => kv.Item2) + .ToFrozenDictionary(); + + } + + public FrozenDictionary Files { get; } + + private IReadOnlyCollection EnabledExtensions { get; } + + /// + public MarkdownFile? TryCreateDocumentationFile(IFileInfo path, IFileSystem readFileSystem) + { + var filePath = new FilePath(path, _context.DocumentationSourceDirectory); + if (Files.TryGetValue(filePath, out var file)) + { + if (file is MarkdownFile markdown) + return markdown; + } + + return null; + } + + private (IFileInfo, DocumentationFile)[] ScanDocumentationFiles(BuildContext build, IDirectoryInfo sourceDirectory) => + [.. build.ReadFileSystem.Directory + .EnumerateFiles(sourceDirectory.FullName, "*.*", SearchOption.AllDirectories) + .Select(f => build.ReadFileSystem.FileInfo.New(f)) + .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden) && !f.Attributes.HasFlag(FileAttributes.System)) + .Where(f => !f.Directory!.Attributes.HasFlag(FileAttributes.Hidden) && !f.Directory!.Attributes.HasFlag(FileAttributes.System)) + // skip hidden folders + .Where(f => !Path.GetRelativePath(sourceDirectory.FullName, f.FullName).StartsWith('.')) + .Select(file => file.Extension switch + { + ".jpg" => (file, new ImageFile(file, sourceDirectory, build.Git.RepositoryName, "image/jpeg")), + ".jpeg" => (file, new ImageFile(file, sourceDirectory, build.Git.RepositoryName, "image/jpeg")), + ".gif" => (file, new ImageFile(file, sourceDirectory, build.Git.RepositoryName, "image/gif")), + ".svg" => (file, new ImageFile(file, sourceDirectory, build.Git.RepositoryName, "image/svg+xml")), + ".png" => (file, new ImageFile(file, sourceDirectory, build.Git.RepositoryName)), + ".md" => (file, CreateMarkDownFile(file, build)), + _ => (file, DefaultFileHandling(file, sourceDirectory)) + })]; + + private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext context) + { + var sourceDirectory = context.DocumentationSourceDirectory; + var relativePath = Path.GetRelativePath(sourceDirectory.FullName, file.FullName); + if (context.Configuration.Exclude.Any(g => g.IsMatch(relativePath))) + return new ExcludedFile(file, sourceDirectory, context.Git.RepositoryName); + + if (relativePath.Contains("_snippets")) + return new SnippetFile(file, sourceDirectory, context.Git.RepositoryName); + + // we ignore files in folders that start with an underscore + var folder = Path.GetDirectoryName(relativePath); + if (folder is not null && (folder.Contains($"{Path.DirectorySeparatorChar}_", StringComparison.Ordinal) || folder.StartsWith('_'))) + return new ExcludedFile(file, sourceDirectory, context.Git.RepositoryName); + + // Todo re-enable not included check else where + // var config = context.ConfigurationYaml; + //if (config.Files.Contains(relativePath)) + return ExtensionOrDefaultMarkdown(); + + //context.Collector.EmitError(config.SourceFile, $"Not linked in toc: {relativePath}"); + //return new ExcludedFile(file, sourceDirectory, context.Git.RepositoryName); + + MarkdownFile ExtensionOrDefaultMarkdown() + { + foreach (var extension in EnabledExtensions) + { + var documentationFile = extension.CreateMarkdownFile(file, sourceDirectory, _markdownParser); + if (documentationFile is not null) + return documentationFile; + } + return new MarkdownFile(file, sourceDirectory, _markdownParser, context); + } + } + + + private DocumentationFile DefaultFileHandling(IFileInfo file, IDirectoryInfo sourceDirectory) + { + foreach (var extension in EnabledExtensions) + { + var documentationFile = extension.CreateDocumentationFile(file, _markdownParser); + if (documentationFile is not null) + return documentationFile; + } + return new ExcludedFile(file, sourceDirectory, _context.Git.RepositoryName); + } + +} diff --git a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs b/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs deleted file mode 100644 index 6dd24c352..000000000 --- a/src/Elastic.Markdown/IO/Navigation/CrossLinkNavigationItem.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using Elastic.Documentation.Site.Navigation; - -namespace Elastic.Markdown.IO.Navigation; - -[DebuggerDisplay("CrossLink: {Url}")] -public record CrossLinkNavigationItem : ILeafNavigationItem -{ - public CrossLinkNavigationItem(Uri crossLinkUri, Uri resolvedUrl, string title, DocumentationGroup group, bool hidden = false) - { - CrossLink = crossLinkUri; - Url = resolvedUrl.ToString(); - NavigationTitle = title; - Parent = group; - NavigationRoot = group.NavigationRoot; - Hidden = hidden; - } - - public INodeNavigationItem? Parent { get; set; } - public IRootNavigationItem NavigationRoot { get; } - - public Uri CrossLink { get; } - public string Url { get; } - public string NavigationTitle { get; } - public int NavigationIndex { get; set; } - public bool Hidden { get; } - public bool IsCrossLink => true; // This is always a cross-link - public INavigationModel Model => null!; // Cross-link has no local model -} diff --git a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs b/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs deleted file mode 100644 index fd121a2c5..000000000 --- a/src/Elastic.Markdown/IO/Navigation/DocumentationGroup.cs +++ /dev/null @@ -1,255 +0,0 @@ -// 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.Diagnostics; -using Elastic.Documentation; -using Elastic.Documentation.Configuration; -using Elastic.Documentation.Configuration.TableOfContents; -using Elastic.Documentation.Extensions; -using Elastic.Documentation.Site.Navigation; - -namespace Elastic.Markdown.IO.Navigation; - -[DebuggerDisplay("Toc: {Depth} {NavigationSource} > ({NavigationItems.Count} items)")] -public class DocumentationGroup : INodeNavigationItem -{ - private readonly TableOfContentsTreeCollector _treeCollector; - - public string Id { get; } - - public IRootNavigationItem NavigationRoot { get; protected init; } - - public Uri NavigationSource { get; set; } - - public MarkdownFile Index { get; } - - public string Url => Index.Url; - - public string NavigationTitle => Index.NavigationTitle; - - public bool Hidden { get; set; } - - public int NavigationIndex { get; set; } - - public bool IsCrossLink => false; // Documentation groups are never cross-links - - private IReadOnlyCollection FilesInOrder { get; } - - private IReadOnlyCollection GroupsInOrder { get; } - - public IReadOnlyCollection NavigationItems { get; set; } - - public int Depth { get; } - - public INodeNavigationItem? Parent { get; set; } - - public string FolderName { get; } - - private readonly IRootNavigationItem? _root; - - protected virtual IRootNavigationItem DefaultNavigation => - _root ?? throw new InvalidOperationException("root navigation's model is not of type MarkdownFile"); - - protected DocumentationGroup(string folderName, - TableOfContentsTreeCollector treeCollector, - BuildContext context, - NavigationLookups lookups, - Uri navigationSource, - ref int fileIndex, - int depth, - IRootNavigationItem? toplevelTree, - DocumentationGroup? parent, - MarkdownFile? virtualIndexFile = null - ) - { - Parent = parent; - FolderName = folderName; - NavigationSource = navigationSource; - _treeCollector = treeCollector; - Depth = depth; - // Virtual calls don't use state, so while ugly not an issue - // We'll need to address this more structurally - // ReSharper disable VirtualMemberCallInConstructor - _root = toplevelTree; - toplevelTree ??= DefaultNavigation; - if (parent?.Depth == 0) - toplevelTree = DefaultNavigation; - // ReSharper enable VirtualMemberCallInConstructor - NavigationRoot = toplevelTree; - Index = ProcessTocItems(context, toplevelTree, lookups, depth, virtualIndexFile, ref fileIndex, out var groups, out var files, out var navigationItems); - - GroupsInOrder = groups; - FilesInOrder = files; - NavigationItems = navigationItems; - Id = ShortId.Create(NavigationSource.ToString(), FolderName); - - FilesInOrder = [.. FilesInOrder.Except([Index])]; - } - - private MarkdownFile ProcessTocItems(BuildContext context, - IRootNavigationItem rootNavigationItem, - NavigationLookups lookups, - int depth, - MarkdownFile? virtualIndexFile, - ref int fileIndex, - out List groups, - out List files, - out List navigationItems - ) - { - groups = []; - navigationItems = []; - files = []; - var fileReferences = lookups.TableOfContents.OfType().ToArray(); - var indexFile = virtualIndexFile; - FileReference? indexReference = null; - if (indexFile is null) - { - indexReference = - fileReferences.FirstOrDefault(f => f.RelativePath.EndsWith("index.md")) - ?? fileReferences.FirstOrDefault(); - } - - var list = navigationItems; - - void AddToNavigationItems(INavigationItem item, ref int fileIndex) - { - item.NavigationIndex = Interlocked.Increment(ref fileIndex); - list.Add(item); - } - - foreach (var tocItem in lookups.TableOfContents) - { - if (tocItem is CrossLinkReference crossLink) - { - // Validate that cross-link has a title - if (string.IsNullOrWhiteSpace(crossLink.Title)) - { - context.EmitError(context.ConfigurationPath, - $"Cross-link entries must have a 'title' specified. Cross-link: {crossLink.CrossLinkUri}"); - continue; - } - - if (!lookups.CrossLinkResolver.TryResolve(msg => context.EmitError(context.ConfigurationPath, msg), crossLink.CrossLinkUri, out var resolvedUrl)) - continue; // the crosslink resolver will emit an error already - - // Create a special navigation item for cross-repository links - var crossLinkItem = new CrossLinkNavigationItem(crossLink.CrossLinkUri, resolvedUrl, crossLink.Title, this, crossLink.Hidden); - AddToNavigationItems(crossLinkItem, ref fileIndex); - - } - else if (tocItem is FileReference file) - { - if (!lookups.FlatMappedFiles.TryGetValue(file.RelativePath, out var d)) - { - context.EmitError(context.ConfigurationPath, - $"The following file could not be located: {file.RelativePath} it may be excluded from the build in docset.yml"); - continue; - } - - if (d is ExcludedFile excluded && excluded.RelativePath.EndsWith(".md")) - { - context.EmitError(context.ConfigurationPath, $"{excluded.RelativePath} matches exclusion glob from docset.yml yet appears in TOC"); - continue; - } - - if (d is not MarkdownFile md) - { - if (d is not SnippetFile) - context.EmitError(context.ConfigurationPath, $"{d.RelativePath} is not a Markdown file."); - continue; - } - - md.PartOfNavigation = true; - - // TODO these have to be refactor to be pure navigational properties - md.ScopeDirectory = file.TableOfContentsScope.ScopeDirectory; - md.NavigationRoot = rootNavigationItem; - - foreach (var extension in lookups.EnabledExtensions) - extension.Visit(d, tocItem); - - if (file.Children.Count > 0) - { - if (file.Hidden) - context.EmitError(context.ConfigurationPath, $"The following file is hidden but has children: {file.RelativePath}"); - var group = new DocumentationGroup(md.RelativePath, - _treeCollector, context, lookups with - { - TableOfContents = file.Children, - }, NavigationSource, ref fileIndex, depth + 1, rootNavigationItem, this, md); - groups.Add(group); - AddToNavigationItems(group, ref fileIndex); - indexFile ??= md; - continue; - } - - files.Add(md); - if (file.RelativePath.EndsWith("index.md")) - indexFile ??= md; - else if (indexReference == file) - indexFile ??= md; - - // Add the page to navigation items unless it's the index file - // the index file can either be the discovered `index.md` or the parent group's - // explicit index page. E.g., when grouping related files together. - // If the page is referenced as hidden in the TOC do not include it in the navigation - if (indexFile != md) - AddToNavigationItems(new FileNavigationItem(md, this, file.Hidden), ref fileIndex); - } - else if (tocItem is FolderReference folder) - { - var children = folder.Children; - if (children.Count == 0 && lookups.FilesGroupedByFolder.TryGetValue(folder.RelativePath, out var documentationFiles)) - { - children = - [ - .. documentationFiles - .Select(d => new FileReference(folder.TableOfContentsScope, d.RelativePath, false, [])) - ]; - } - - DocumentationGroup group; - if (folder is TocReference tocReference) - { - var toc = new TableOfContentsTree(tocReference.Source, folder.RelativePath, _treeCollector, context, lookups with - { - TableOfContents = children - }, ref fileIndex, depth + 1, rootNavigationItem, this); - - group = toc; - AddToNavigationItems(toc, ref fileIndex); - } - else - { - group = new DocumentationGroup(folder.RelativePath, _treeCollector, context, lookups with - { - TableOfContents = children - }, NavigationSource, ref fileIndex, depth + 1, rootNavigationItem, this); - AddToNavigationItems(group, ref fileIndex); - } - - groups.Add(group); - } - } - - var index = indexFile ?? files.FirstOrDefault() ?? groups.FirstOrDefault()?.Index; - return index ?? throw new InvalidOperationException($"No index file found. {depth}, {fileIndex}"); - } - - private bool _resolved; - - public async Task Resolve(Cancel ctx = default) - { - if (_resolved) - return; - - await Parallel.ForEachAsync(FilesInOrder, ctx, async (file, token) => await file.MinimalParseAsync(token)); - await Parallel.ForEachAsync(GroupsInOrder, ctx, async (group, token) => await group.Resolve(token)); - - _ = await Index.MinimalParseAsync(ctx); - - _resolved = true; - } -} diff --git a/src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs b/src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs deleted file mode 100644 index ff34bd750..000000000 --- a/src/Elastic.Markdown/IO/Navigation/FileNavigationItem.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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.Diagnostics; -using Elastic.Documentation.Site.Navigation; - -namespace Elastic.Markdown.IO.Navigation; - -[DebuggerDisplay("Current: {Model.RelativePath}")] -public record FileNavigationItem(MarkdownFile Model, DocumentationGroup Group, bool Hidden = false) - : ILeafNavigationItem -{ - public INodeNavigationItem? Parent { get; set; } = Group; - public IRootNavigationItem NavigationRoot { get; } = Group.NavigationRoot; - public string Url => Model.Url; - public string NavigationTitle => Model.NavigationTitle; - public int NavigationIndex { get; set; } - public bool IsCrossLink => false; // File navigation items are never cross-links -} diff --git a/src/Elastic.Markdown/IO/Navigation/TableOfContentsTree.cs b/src/Elastic.Markdown/IO/Navigation/TableOfContentsTree.cs deleted file mode 100644 index d9caeb648..000000000 --- a/src/Elastic.Markdown/IO/Navigation/TableOfContentsTree.cs +++ /dev/null @@ -1,62 +0,0 @@ -// 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.Diagnostics; -using Elastic.Documentation.Configuration; -using Elastic.Documentation.Site.Navigation; - -namespace Elastic.Markdown.IO.Navigation; - -[DebuggerDisplay("Toc: {Depth} {NavigationSource} > ({NavigationItems.Count} items)")] -public class TableOfContentsTree : DocumentationGroup, IRootNavigationItem -{ - public Uri Source { get; } - - public TableOfContentsTreeCollector TreeCollector { get; } - - public TableOfContentsTree( - Uri source, - BuildContext context, - NavigationLookups lookups, - TableOfContentsTreeCollector treeCollector, - ref int fileIndex) - : base(".", treeCollector, context, lookups, source, ref fileIndex, 0, null, null) - { - TreeCollector = treeCollector; - NavigationRoot = this; - - Source = source; - TreeCollector.Collect(source, this); - - //edge case if a tree only holds a single group, ensure we collapse it down to the root (this) - if (NavigationItems.Count == 1 && NavigationItems.First() is DocumentationGroup { NavigationItems.Count: 0 }) - NavigationItems = []; - - - } - - internal TableOfContentsTree( - Uri source, - string folderName, - TableOfContentsTreeCollector treeCollector, - BuildContext context, - NavigationLookups lookups, - ref int fileIndex, - int depth, - IRootNavigationItem toplevelTree, - DocumentationGroup? parent - ) : base(folderName, treeCollector, context, lookups, source, ref fileIndex, depth, toplevelTree, parent) - { - Source = source; - TreeCollector = treeCollector; - NavigationRoot = this; - TreeCollector.Collect(source, this); - } - - protected override IRootNavigationItem DefaultNavigation => this; - - // We rely on IsPrimaryNavEnabled to determine if we should show the dropdown - /// - public bool IsUsingNavigationDropdown => false; -} diff --git a/src/Elastic.Markdown/IO/Navigation/TableOfContentsTreeCollector.cs b/src/Elastic.Markdown/IO/Navigation/TableOfContentsTreeCollector.cs deleted file mode 100644 index bee43324e..000000000 --- a/src/Elastic.Markdown/IO/Navigation/TableOfContentsTreeCollector.cs +++ /dev/null @@ -1,17 +0,0 @@ -// 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.Diagnostics.CodeAnalysis; - -namespace Elastic.Markdown.IO.Navigation; - -public class TableOfContentsTreeCollector -{ - private Dictionary NestedTableOfContentsTrees { get; } = []; - - public void Collect(Uri source, TableOfContentsTree tree) => NestedTableOfContentsTrees[source] = tree; - - public bool TryGetTableOfContentsTree(Uri source, [NotNullWhen(true)] out TableOfContentsTree? tree) => - NestedTableOfContentsTrees.TryGetValue(source, out tree); -} diff --git a/src/Elastic.Markdown/Layout/_LandingPage.cshtml b/src/Elastic.Markdown/Layout/_LandingPage.cshtml index ebabf8ca6..574fa152f 100644 --- a/src/Elastic.Markdown/Layout/_LandingPage.cshtml +++ b/src/Elastic.Markdown/Layout/_LandingPage.cshtml @@ -13,7 +13,7 @@

    Welcome to the docs that cover all changes in Elastic Stack 9.0.0 and later, including Elastic Stack @Model.CurrentVersion and Elastic Cloud Serverless. For easy reference, changes in 9.1.0 are marked inline. For details, check Understanding versioning and availability.

    - + Elastic Fundamentals diff --git a/src/Elastic.Markdown/MarkdownLayoutViewModel.cs b/src/Elastic.Markdown/MarkdownLayoutViewModel.cs index 7b947637b..954b0dc60 100644 --- a/src/Elastic.Markdown/MarkdownLayoutViewModel.cs +++ b/src/Elastic.Markdown/MarkdownLayoutViewModel.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information using Elastic.Documentation.Configuration.LegacyUrlMappings; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site; -using Elastic.Documentation.Site.Navigation; namespace Elastic.Markdown; diff --git a/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs b/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs index e9ede80fe..2a30bb35d 100644 --- a/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Image/ImageBlock.cs @@ -118,7 +118,7 @@ private void ExtractImageUrl(ParserContext context) else this.EmitError($"`{imageUrl}` does not exist. resolved to `{file}"); - if (context.DocumentationFileLookup(context.MarkdownSourcePath) is MarkdownFile currentMarkdown) + if (context.TryFindDocument(context.MarkdownSourcePath) is MarkdownFile currentMarkdown) { if (!file.Directory!.FullName.StartsWith(currentMarkdown.ScopeDirectory.FullName + Path.DirectorySeparatorChar)) this.EmitWarning($"Image '{imageUrl}' is referenced out of table of contents scope '{currentMarkdown.ScopeDirectory}'."); diff --git a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs index 5369468df..32bd7c3ed 100644 --- a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs @@ -2,7 +2,13 @@ // 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.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Elastic.Documentation.Links.CrossLinks; +using Elastic.Documentation.Navigation; using Elastic.Markdown.Helpers; +using Elastic.Markdown.IO; using Microsoft.AspNetCore.Html; namespace Elastic.Markdown.Myst.Directives.Stepper; @@ -13,6 +19,35 @@ public class StepViewModel : DirectiveViewModel public required string Anchor { get; init; } public required int HeadingLevel { get; init; } + public class StepCrossNavigationLookupProvider : IPositionalNavigation + { + public static StepCrossNavigationLookupProvider Instance { get; } = new(); + + /// + public FrozenDictionary NavigationIndexedByOrder { get; } = new Dictionary().ToFrozenDictionary(); + + /// + public FrozenDictionary> NavigationIndexedByCrossLink { get; } = + new Dictionary>().ToFrozenDictionary(); + + /// + public ConditionalWeakTable MarkdownNavigationLookup { get; } = []; + } + + public class StepCrossLinkResolver : ICrossLinkResolver + { + public static StepCrossLinkResolver Instance { get; } = new(); + /// + public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) + { + resolvedUri = null; + return false; + } + + /// + public IUriEnvironmentResolver UriResolver { get; } = new IsolatedBuildEnvironmentUriResolver(); + } + /// /// Renders the title with full markdown processing (substitutions, links, emphasis, etc.) /// @@ -29,8 +64,10 @@ public HtmlString RenderTitle() { MarkdownSourcePath = directiveBlock.CurrentFile, YamlFrontMatter = yamlFrontMatter, - DocumentationFileLookup = _ => null!, - CrossLinkResolver = null! + TryFindDocument = _ => null!, + TryFindDocumentByRelativePath = _ => null!, + CrossLinkResolver = StepCrossLinkResolver.Instance, + PositionalNavigation = StepCrossNavigationLookupProvider.Instance }); var document = Markdig.Markdown.Parse(Title, MarkdownParser.Pipeline, context); diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index 3b8338ec5..f73721d42 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -6,6 +6,7 @@ using System.IO.Abstractions; using System.Runtime.InteropServices; using System.Text.RegularExpressions; +using Elastic.Documentation.Extensions; using Elastic.Documentation.Links; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Helpers; @@ -215,12 +216,12 @@ private static void ProcessInternalLink(LinkInline link, InlineProcessor process UpdateLinkUrl(link, linkMarkdown, url, context, anchor); } - private static MarkdownFile? SetLinkData(LinkInline link, InlineProcessor processor, ParserContext context, - IFileInfo file, string url) + private static MarkdownFile? SetLinkData(LinkInline link, InlineProcessor processor, ParserContext context, IFileInfo file, string url) { - if (context.DocumentationFileLookup(context.MarkdownSourcePath) is MarkdownFile currentMarkdown) + if (context.TryFindDocument(context.MarkdownSourcePath) is MarkdownFile currentMarkdown) { - link.SetData(nameof(currentMarkdown.NavigationRoot), currentMarkdown.NavigationRoot); + if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(currentMarkdown, out var navigationLookup)) + link.SetData("NavigationRoot", navigationLookup.NavigationRoot); if (link.IsImage) { @@ -231,9 +232,13 @@ private static void ProcessInternalLink(LinkInline link, InlineProcessor process } - var linkMarkdown = context.DocumentationFileLookup(file) as MarkdownFile; + var linkMarkdown = context.TryFindDocument(file) as MarkdownFile; if (linkMarkdown is not null) - link.SetData($"Target{nameof(currentMarkdown.NavigationRoot)}", linkMarkdown.NavigationRoot); + { + if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(linkMarkdown, out var navigationLookup)) + link.SetData("TargetNavigationRoot", navigationLookup.NavigationRoot); + + } return linkMarkdown; } @@ -316,9 +321,16 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s var newUrl = url; if (linkMarkdown is not null) { - // if url is null it's an anchor link - if (!string.IsNullOrEmpty(url)) - newUrl = linkMarkdown.Url; + if (context.PositionalNavigation.MarkdownNavigationLookup.TryGetValue(linkMarkdown, out var navigationLookup) + && !string.IsNullOrEmpty(navigationLookup.Url)) + { + // Navigation URLs are absolute and start with / + // Apply the same prefix handling as UpdateRelativeUrl would for absolute paths + newUrl = navigationLookup.Url; + var urlPathPrefix = context.Build.UrlPathPrefix ?? string.Empty; + if (!string.IsNullOrWhiteSpace(urlPathPrefix) && !newUrl.StartsWith(urlPathPrefix)) + newUrl = $"{urlPathPrefix.TrimEnd('/')}{newUrl}"; + } } else newUrl = UpdateRelativeUrl(context, url); @@ -348,16 +360,20 @@ private static void UpdateLinkUrl(LinkInline link, MarkdownFile? linkMarkdown, s public static string UpdateRelativeUrl(ParserContext context, string url) { var urlPathPrefix = context.Build.UrlPathPrefix ?? string.Empty; + + var fi = context.MarkdownSourcePath; + var newUrl = url; if (!newUrl.StartsWith('/') && !string.IsNullOrEmpty(newUrl)) { - var subPrefix = context.CurrentUrlPath.Length >= urlPathPrefix.Length - ? context.CurrentUrlPath[urlPathPrefix.Length..] - : urlPathPrefix; + var path = Path.GetFullPath(fi.FileSystem.Path.Combine(fi.Directory!.FullName, newUrl)); + var pathInfo = fi.FileSystem.FileInfo.New(path); + pathInfo = pathInfo.EnsureSubPathOf(context.Configuration.ScopeDirectory, newUrl); + var relativePath = fi.FileSystem.Path.GetRelativePath(context.Configuration.ScopeDirectory.FullName, pathInfo.FullName).OptionalWindowsReplace(); // if we are trying to resolve a relative url from a _snippet folder ensure we eat the _snippet folder // as it's not part of url by chopping of the extra parent navigation - if (newUrl.StartsWith("../") && context.DocumentationFileLookup(context.MarkdownSourcePath) is SnippetFile snippet) + if (newUrl.StartsWith("../") && context.TryFindDocument(context.MarkdownSourcePath) is SnippetFile snippet) { //figure out how many nested folders inside `_snippets` we need to ignore. var d = snippet.SourceFile.Directory; @@ -376,17 +392,8 @@ public static string UpdateRelativeUrl(ParserContext context, string url) offset--; } } - - // TODO check through context.DocumentationFileLookup if file is index vs "index.md" check - var markdownPath = context.MarkdownSourcePath; - // if the current path is an index e.g /reference/cloud-k8s/ - // './' current path lookups should be relative to sub-path. - // If it's not e.g /reference/cloud-k8s/api-docs/ these links should resolve on folder up. - var lastIndexPath = subPrefix.LastIndexOf('/'); - if (lastIndexPath >= 0 && markdownPath.Name != "index.md") - subPrefix = subPrefix[..lastIndexPath]; - var combined = '/' + Path.Combine(subPrefix, newUrl).TrimStart('/'); - newUrl = Path.GetFullPath(combined); + else + newUrl = $"/{Path.Combine(urlPathPrefix, relativePath).OptionalWindowsReplace().TrimStart('/')}"; } // When running on Windows, path traversal results must be normalized prior to being used in a URL @@ -398,7 +405,7 @@ public static string UpdateRelativeUrl(ParserContext context, string url) newUrl = newUrl[2..]; } - if (!string.IsNullOrWhiteSpace(newUrl) && !string.IsNullOrWhiteSpace(urlPathPrefix)) + if (!string.IsNullOrWhiteSpace(newUrl) && !string.IsNullOrWhiteSpace(urlPathPrefix) && !newUrl.StartsWith(urlPathPrefix)) newUrl = $"{urlPathPrefix.TrimEnd('/')}{newUrl}"; // eat overall path prefix since its gets appended later diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 1c530fba4..b065e23c5 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -36,16 +36,16 @@ public Task ParseAsync(IFileInfo path, YamlFrontMatter? matter public Task MinimalParseAsync(IFileInfo path, Cancel ctx) => ParseFromFile(path, null, MinimalPipeline, true, ctx); - private Task ParseFromFile( - IFileInfo path, YamlFrontMatter? matter, MarkdownPipeline pipeline, bool skip, Cancel ctx - ) + private Task ParseFromFile(IFileInfo path, YamlFrontMatter? matter, MarkdownPipeline pipeline, bool skip, Cancel ctx) { var state = new ParserState(Build) { MarkdownSourcePath = path, YamlFrontMatter = matter, - DocumentationFileLookup = Resolvers.DocumentationFileLookup, + TryFindDocument = Resolvers.TryFindDocument, + TryFindDocumentByRelativePath = Resolvers.TryFindDocumentByRelativePath, CrossLinkResolver = Resolvers.CrossLinkResolver, + PositionalNavigation = Resolvers.PositionalNavigation, SkipValidation = skip }; var context = new ParserContext(state); @@ -67,7 +67,9 @@ public static MarkdownDocument ParseMarkdownStringAsync(BuildContext build, IPar { MarkdownSourcePath = path, YamlFrontMatter = matter, - DocumentationFileLookup = resolvers.DocumentationFileLookup, + TryFindDocument = resolvers.TryFindDocument, + TryFindDocumentByRelativePath = resolvers.TryFindDocumentByRelativePath, + PositionalNavigation = resolvers.PositionalNavigation, CrossLinkResolver = resolvers.CrossLinkResolver }; var context = new ParserContext(state); @@ -86,8 +88,10 @@ public static Task ParseSnippetAsync(BuildContext build, IPars { MarkdownSourcePath = path, YamlFrontMatter = matter, - DocumentationFileLookup = resolvers.DocumentationFileLookup, + TryFindDocument = resolvers.TryFindDocument, + TryFindDocumentByRelativePath = resolvers.TryFindDocumentByRelativePath, CrossLinkResolver = resolvers.CrossLinkResolver, + PositionalNavigation = resolvers.PositionalNavigation, ParentMarkdownPath = parentPath }; var context = new ParserContext(state); diff --git a/src/Elastic.Markdown/Myst/ParserContext.cs b/src/Elastic.Markdown/Myst/ParserContext.cs index 5288ce886..d4c541035 100644 --- a/src/Elastic.Markdown/Myst/ParserContext.cs +++ b/src/Elastic.Markdown/Myst/ParserContext.cs @@ -28,14 +28,20 @@ processor.Context as ParserContext public interface IParserResolvers { ICrossLinkResolver CrossLinkResolver { get; } - Func DocumentationFileLookup { get; } + Func TryFindDocument { get; } + Func TryFindDocumentByRelativePath { get; } + IPositionalNavigation PositionalNavigation { get; } } public record ParserResolvers : IParserResolvers { public required ICrossLinkResolver CrossLinkResolver { get; init; } - public required Func DocumentationFileLookup { get; init; } + public required Func TryFindDocument { get; init; } + + public required Func TryFindDocumentByRelativePath { get; init; } + + public required IPositionalNavigation PositionalNavigation { get; init; } } public record ParserState(BuildContext Build) : ParserResolvers @@ -59,9 +65,11 @@ public class ParserContext : MarkdownParserContext, IParserResolvers public YamlFrontMatter? YamlFrontMatter { get; } public BuildContext Build { get; } public bool SkipValidation { get; } - public Func DocumentationFileLookup { get; } + public Func TryFindDocument { get; } + public Func TryFindDocumentByRelativePath { get; } public IReadOnlyDictionary Substitutions { get; } public IReadOnlyDictionary ContextSubstitutions { get; } + public IPositionalNavigation PositionalNavigation { get; } public ParserContext(ParserState state) { @@ -73,11 +81,16 @@ public ParserContext(ParserState state) CrossLinkResolver = state.CrossLinkResolver; MarkdownSourcePath = state.MarkdownSourcePath; - DocumentationFileLookup = state.DocumentationFileLookup; + TryFindDocument = state.TryFindDocument; + TryFindDocumentByRelativePath = state.TryFindDocumentByRelativePath; + PositionalNavigation = state.PositionalNavigation; + CurrentUrlPath = string.Empty; - CurrentUrlPath = DocumentationFileLookup(state.ParentMarkdownPath ?? MarkdownSourcePath) is MarkdownFile md - ? md.Url - : string.Empty; + if (TryFindDocument(state.ParentMarkdownPath ?? MarkdownSourcePath) is MarkdownFile document) + { + if (PositionalNavigation.MarkdownNavigationLookup.TryGetValue(document, out var navigationLookup)) + CurrentUrlPath = navigationLookup.Url; + } if (SkipValidation && string.IsNullOrEmpty(CurrentUrlPath)) { @@ -108,4 +121,5 @@ public ParserContext(ParserState state) ContextSubstitutions = contextSubs; } + } diff --git a/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs b/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs index 1fc323598..38a5390b3 100644 --- a/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs @@ -2,8 +2,8 @@ // 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 Elastic.Documentation.Navigation; using Elastic.Documentation.Site; -using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.IO; using Markdig; using Markdig.Renderers; @@ -36,8 +36,8 @@ protected override void Write(HtmlRenderer renderer, LinkInline link) if (link.Url?.StartsWith('/') == true || isCrossLink) { - var currentRootNavigation = link.GetData(nameof(MarkdownFile.NavigationRoot)) as INodeNavigationItem; - var targetRootNavigation = link.GetData($"Target{nameof(MarkdownFile.NavigationRoot)}") as INodeNavigationItem; + var currentRootNavigation = link.GetData("NavigationRoot") as INodeNavigationItem; + var targetRootNavigation = link.GetData("TargetNavigationRoot") as INodeNavigationItem; var hasSameTopLevelGroup = !isCrossLink && (currentRootNavigation?.Id == targetRootNavigation?.Id); _ = renderer.Write($" hx-select-oob=\"{Htmx.GetHxSelectOob(hasSameTopLevelGroup)}\""); _ = renderer.Write($" preload=\"{Htmx.Preload}\""); diff --git a/src/Elastic.Markdown/Page/IndexViewModel.cs b/src/Elastic.Markdown/Page/IndexViewModel.cs index 7c3ce4761..6915f8d17 100644 --- a/src/Elastic.Markdown/Page/IndexViewModel.cs +++ b/src/Elastic.Markdown/Page/IndexViewModel.cs @@ -9,10 +9,9 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; -using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; namespace Elastic.Markdown.Page; diff --git a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs index bd7dbf4b1..785089c7b 100644 --- a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs @@ -54,7 +54,7 @@ Cancel ctx formatterStats[formatter.Name] = 0; // Only process markdown files that are part of the documentation set - foreach (var docFile in set.Files.OfType()) + foreach (var docFile in set.Files.Values.OfType()) { if (ctx.IsCancellationRequested) break; diff --git a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs index f7ee32689..2a97e79a2 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs @@ -38,6 +38,12 @@ public class AssembleContext : IDocumentationConfigurationContext public IDirectoryInfo OutputDirectory { get; } + /// + public IFileInfo ConfigurationPath { get; } + + /// + public bool AssemblerBuild => true; + public PublishEnvironment Environment { get; } public AssembleContext( @@ -57,6 +63,7 @@ public AssembleContext( Configuration = configuration; ConfigurationFileProvider = configurationContext.ConfigurationFileProvider; + ConfigurationPath = ConfigurationFileProvider.AssemblerFile; VersionsConfiguration = configurationContext.VersionsConfiguration; SynonymsConfiguration = configurationContext.SynonymsConfiguration; Endpoints = configurationContext.Endpoints; diff --git a/src/services/Elastic.Documentation.Assembler/AssembleSources.cs b/src/services/Elastic.Documentation.Assembler/AssembleSources.cs index c7ed54546..30d3a4e16 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleSources.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleSources.cs @@ -11,10 +11,9 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Configuration.LegacyUrlMappings; -using Elastic.Documentation.Configuration.Navigation; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links.CrossLinks; -using Elastic.Markdown.IO.Navigation; using Microsoft.Extensions.Logging; using YamlDotNet.RepresentationModel; @@ -30,10 +29,6 @@ public class AssembleSources public LegacyUrlMappingConfiguration LegacyUrlMappings { get; } - public FrozenDictionary TocConfigurationMapping { get; } - - public TableOfContentsTreeCollector TreeCollector { get; } = new(); - public PublishEnvironmentUriResolver UriResolver { get; } public static async Task AssembleAsync( @@ -52,6 +47,7 @@ Cancel ctx var crossLinkFetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexProvider); var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx); var crossLinkResolver = new CrossLinkResolver(crossLinks, uriResolver); + var logger = logFactory.CreateLogger(); var sources = new AssembleSources( logFactory, @@ -65,7 +61,10 @@ Cancel ctx availableExporters ); foreach (var (_, set) in sources.AssembleSets) + { + logger.LogInformation("Resolving directory tree for {RepositoryName}", set.Checkout.Repository.Name); await set.DocumentationSet.ResolveDirectoryTree(ctx); + } return sources; } @@ -87,47 +86,9 @@ IReadOnlySet availableExporters AssembleContext = assembleContext; AssembleSets = checkouts .Where(c => c.Repository is { Skip: false }) - .Select(c => new AssemblerDocumentationSet(logFactory, assembleContext, c, crossLinkResolver, TreeCollector, configurationContext, - availableExporters)) + .Select(c => new AssemblerDocumentationSet(logFactory, assembleContext, c, crossLinkResolver, configurationContext, availableExporters)) .ToDictionary(s => s.Checkout.Repository.Name, s => s) .ToFrozenDictionary(); - - TocConfigurationMapping = NavigationTocMappings - .Select(kv => - { - var repo = kv.Value.Source.Scheme; - if (!AssembleSets.TryGetValue(repo, out var set)) - throw new Exception($"Unable to find repository: {repo}"); - - var fs = set.BuildContext.ReadFileSystem; - var config = set.BuildContext.Configuration; - var tocDirectory = Path.Combine(config.ScopeDirectory.FullName, kv.Value.Source.Host, kv.Value.Source.AbsolutePath.TrimStart('/')); - var relative = Path.GetRelativePath(config.ScopeDirectory.FullName, tocDirectory); - IFileInfo[] tocFiles = - [ - fs.FileInfo.New(Path.Combine(tocDirectory, "toc.yml")), - fs.FileInfo.New(Path.Combine(tocDirectory, "_toc.yml")), - fs.FileInfo.New(Path.Combine(tocDirectory, "docset.yml")), - fs.FileInfo.New(Path.Combine(tocDirectory, "_docset.yml")) - ]; - var file = tocFiles.FirstOrDefault(f => f.Exists); - if (file is null) - { - assembleContext.Collector.EmitWarning(assembleContext.ConfigurationFileProvider.AssemblerFile, - $"Unable to find toc file in {tocDirectory}"); - file = tocFiles.First(); - } - - var toc = new TableOfContentsConfiguration(config, file, fs.DirectoryInfo.New(tocDirectory), set.BuildContext, 0, relative); - var mapping = new TocConfigurationMapping - { - TopLevel = kv.Value, - RepositoryConfigurationFile = config, - TableOfContentsConfiguration = toc - }; - return new KeyValuePair(kv.Value.Source, mapping); - }) - .ToFrozenDictionary(); } public static FrozenDictionary GetTocMappings(AssembleContext context) @@ -248,8 +209,6 @@ static void ReadBlock( { Source = sourceUri, SourcePathPrefix = pathPrefix, - TopLevelSource = topLevelSource, - ParentSource = parentSource }; entries.Add(new KeyValuePair(sourceUri, tocTopLevelMapping)); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index a9c9aa77e..129251f30 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -2,7 +2,6 @@ // 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.Collections.Frozen; using System.IO.Abstractions; using System.Text; using Actions.Core.Services; @@ -10,9 +9,10 @@ using Elastic.Documentation.Assembler.Sourcing; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; -using Elastic.Documentation.Configuration.Navigation; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.LegacyDocs; +using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -59,12 +59,6 @@ Cancel ctx assembleContext.OutputDirectory.Delete(true); } - _logger.LogInformation("Validating navigation.yml does not contain colliding path prefixes"); - // this validates all path prefixes are unique, early exit if duplicates are detected - if (!GlobalNavigationFile.ValidatePathPrefixes(assembleContext.Collector, assembleContext.ConfigurationFileProvider, assemblyConfiguration) - || assembleContext.Collector.Errors > 0) - return false; - _logger.LogInformation("Get all clone directory information"); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); @@ -75,17 +69,23 @@ Cancel ctx _logger.LogInformation("Preparing all assemble sources for build"); var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, exporters, ctx); - var navigationFile = new GlobalNavigationFile(collector, configurationContext.ConfigurationFileProvider, assemblyConfiguration, assembleSources.TocConfigurationMapping); - _logger.LogInformation("Create global navigation"); - var navigation = new GlobalNavigation(assembleSources, navigationFile); + var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; + var siteNavigationFile = SiteNavigationFile.Deserialize(await fs.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); + var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray(); + var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets, assembleContext.Environment.PathPrefix); + + _logger.LogInformation("Validating navigation.yml does not contain colliding path prefixes"); + // this validates all path prefixes are unique, early exit if duplicates are detected + if (!SiteNavigationFile.ValidatePathPrefixes(assembleContext.Collector, siteNavigationFile, navigationFileInfo) || assembleContext.Collector.Errors > 0) + return false; - var pathProvider = new GlobalNavigationPathProvider(navigationFile, assembleSources, assembleContext); - var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector); + var pathProvider = new GlobalNavigationPathProvider(navigation, assembleSources, assembleContext); + using var htmlWriter = new GlobalNavigationHtmlWriter(logFactory, navigation, collector); var legacyPageChecker = new LegacyPageService(logFactory); var historyMapper = new PageLegacyUrlMapper(legacyPageChecker, assembleContext.VersionsConfiguration, assembleSources.LegacyUrlMappings); - var builder = new AssemblerBuilder(logFactory, assembleContext, navigation, htmlWriter, pathProvider, historyMapper); + var builder = new AssemblerBuilder(logFactory, assembleContext, htmlWriter, pathProvider, historyMapper); await builder.BuildAllAsync(assembleContext.Environment, assembleSources.AssembleSets, exporters, ctx); @@ -116,7 +116,7 @@ Cancel ctx return strict.Value ? collector.Errors + collector.Warnings == 0 : collector.Errors == 0; } - private static async Task EnhanceLlmsTxtFile(AssembleContext context, GlobalNavigation navigation, LlmsNavigationEnhancer enhancer, Cancel ctx) + private static async Task EnhanceLlmsTxtFile(AssembleContext context, SiteNavigation navigation, LlmsNavigationEnhancer enhancer, Cancel ctx) { var llmsTxtPath = Path.Combine(context.OutputDirectory.FullName, "docs", "llms.txt"); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs index e2ee2e24b..be9219d3c 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuilder.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; +using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Serialization; using Elastic.Markdown; using Elastic.Markdown.Exporters; @@ -21,7 +22,6 @@ namespace Elastic.Documentation.Assembler.Building; public class AssemblerBuilder( ILoggerFactory logFactory, AssembleContext context, - GlobalNavigation navigation, GlobalNavigationHtmlWriter writer, GlobalNavigationPathProvider pathProvider, ILegacyUrlMapper? legacyUrlMapper @@ -136,7 +136,6 @@ private async Task BuildAsync(AssemblerDocumentationSet set, I logFactory, HtmlWriter, pathProvider, legacyUrlMapper: LegacyUrlMapper, - positionalNavigation: navigation, markdownExporters: markdownExporters ); return await generator.GenerateAll(ctx); diff --git a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs index 98156b4e0..2059be9d9 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs @@ -5,9 +5,10 @@ using System.Globalization; using System.IO.Abstractions; using System.Xml.Linq; -using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Leaf; using Elastic.Markdown.Extensions.DetectionRules; -using Elastic.Markdown.IO.Navigation; namespace Elastic.Documentation.Assembler.Building; @@ -38,8 +39,8 @@ public void Generate() flattenedNavigationItems .Select(n => n switch { - DocumentationGroup group => (group.Index.Url, NavigationItem: group), - FileNavigationItem file => (file.Model.Url, NavigationItem: file as INavigationItem), + INodeNavigationItem group => (group.Url, NavigationItem: group), + ILeafNavigationItem file => (file.Url, NavigationItem: file as INavigationItem), _ => throw new Exception($"{nameof(SitemapBuilder)}.{nameof(Generate)}: Unhandled navigation item type: {n.GetType()}") }) .Select(n => n.Url) @@ -64,22 +65,20 @@ private static IReadOnlyCollection GetNavigationItems(IReadOnly { switch (item) { - case FileNavigationItem file: - // these are hidden from the navigation programatically. - // TODO find a cleaner way to model this. - if (item.Hidden && file.Model is not DetectionRuleFile) - continue; + case ILeafNavigationItem: + case ILeafNavigationItem: + case ILeafNavigationItem { Hidden: true }: + continue; + case ILeafNavigationItem file: result.Add(file); break; - case DocumentationGroup group: + case INodeNavigationItem group: if (item.Hidden) continue; result.AddRange(GetNavigationItems(group.NavigationItems)); result.Add(group); break; - case CrossLinkNavigationItem: - continue; // we do not emit cross links in the sitemap default: throw new Exception($"{nameof(SitemapBuilder)}.{nameof(GetNavigationItems)}: Unhandled navigation item type: {item.GetType()}"); } diff --git a/src/services/Elastic.Documentation.Assembler/Links/PublishEnvironmentUriResolver.cs b/src/services/Elastic.Documentation.Assembler/Links/PublishEnvironmentUriResolver.cs index eb5381cf3..ba8fe9ba3 100644 --- a/src/services/Elastic.Documentation.Assembler/Links/PublishEnvironmentUriResolver.cs +++ b/src/services/Elastic.Documentation.Assembler/Links/PublishEnvironmentUriResolver.cs @@ -4,144 +4,187 @@ using System.Collections.Frozen; using Elastic.Documentation.Configuration.Assembler; -using Elastic.Documentation.Configuration.Navigation; -using Elastic.Documentation.Extensions; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Links.CrossLinks; namespace Elastic.Documentation.Assembler.Links; +/// +/// Resolves cross-link URIs (e.g., elasticsearch://path/to/file.md) to absolute HTTP URLs +/// based on the navigation configuration's source → path_prefix mappings. +/// public class PublishEnvironmentUriResolver : IUriEnvironmentResolver { - private readonly FrozenDictionary _topLevelMappings; - private Uri BaseUri { get; } - - private PublishEnvironment PublishEnvironment { get; } - - private IReadOnlyList TableOfContentsPrefixes { get; } - - public PublishEnvironmentUriResolver(FrozenDictionary topLevelMappings, PublishEnvironment environment) + private readonly FrozenDictionary _navigationMappings; + private readonly Uri _baseUri; + private readonly string? _pathPrefix; + + /// + /// Creates a new resolver that maps cross-link URIs to absolute URLs. + /// + /// Mappings from navigation.yml (toc sources to path prefixes) + /// The publish environment containing base URI and optional path prefix + public PublishEnvironmentUriResolver(FrozenDictionary navigationMappings, PublishEnvironment environment) { - _topLevelMappings = topLevelMappings; - PublishEnvironment = environment; - - TableOfContentsPrefixes = [..topLevelMappings - .Values - .Select(p => - { - var source = p.Source.ToString(); - return source.EndsWith(":///", StringComparison.OrdinalIgnoreCase) ? source[..^1] : source; - }) - .OrderByDescending(v => v.Length) - ]; + _navigationMappings = navigationMappings; + _pathPrefix = environment.PathPrefix; if (!Uri.TryCreate(environment.Uri, UriKind.Absolute, out var uri)) throw new Exception($"Could not parse uri {environment.Uri} in environment {environment}"); - BaseUri = uri; + _baseUri = uri; } + /// + /// Resolves a cross-link URI to an absolute HTTP URL using navigation mappings. + /// + /// The cross-link URI (e.g., elasticsearch://reference/query-dsl) + /// The relative file path within the repository (e.g., reference/query-dsl), already URL-formatted + /// The absolute HTTP URL based on navigation.yml mappings + /// + /// Given navigation.yml has: + /// - toc: elasticsearch://reference + /// path_prefix: docs/elasticsearch/reference + /// + /// Input: crossLinkUri = elasticsearch://reference/query-dsl, path = reference/query-dsl + /// Output: https://www.elastic.co/docs/elasticsearch/reference/query-dsl + /// public Uri Resolve(Uri crossLinkUri, string path) { - var subPath = GetSubPathPrefix(crossLinkUri, ref path); + // The path parameter is the repository-relative path from links.json, converted to URL format + // Example: elasticsearch://reference/query-dsl/bool-query.md → path = "reference/query-dsl/bool-query" + + // Find the navigation mapping for this source + var mapping = FindBestMatchForSource(crossLinkUri); - var fullPath = (PublishEnvironment.PathPrefix, subPath) switch + if (mapping != null) { - (null or "", null or "") => path, - (null or "", var p) => $"{p}/{path.TrimStart('/')}", - (var p, null or "") => $"{p}/{path.TrimStart('/')}", - var (p, pp) => $"{p}/{pp}/{path.TrimStart('/')}" - }; + // The navigation defines how this source maps to a URL path + // Extract what part of 'path' is beyond the source prefix + var sourcePrefix = $"{mapping.Source.Host}/{mapping.Source.AbsolutePath.TrimStart('/')}".Trim('/'); + var remainingPath = path; - return new Uri(BaseUri, fullPath); - } + // If the path starts with the source prefix, get the remainder + if (!string.IsNullOrEmpty(sourcePrefix) && path.StartsWith(sourcePrefix, StringComparison.Ordinal)) + { + remainingPath = path.Length > sourcePrefix.Length + ? path[sourcePrefix.Length..].TrimStart('/') + : string.Empty; + } - public static string MarkdownPathToUrlPath(string path) - { - if (path.EndsWith("/index.md", StringComparison.OrdinalIgnoreCase)) - path = path[..^8]; - if (path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) - path = path[..^3]; - return path; + // Build final path: path_prefix + remaining path + var finalPath = string.IsNullOrEmpty(remainingPath) + ? mapping.SourcePathPrefix + : $"{mapping.SourcePathPrefix}/{remainingPath}"; - } + // Apply environment prefix if present + if (!string.IsNullOrEmpty(_pathPrefix)) + finalPath = $"{_pathPrefix}/{finalPath.TrimStart('/')}"; - public string[] ResolveToSubPaths(Uri crossLinkUri, string path) - { - var lookup = crossLinkUri.ToString().TrimEnd('/').AsSpan(); - if (lookup.EndsWith("index.md", StringComparison.Ordinal)) - lookup = lookup[..^8]; - if (lookup.EndsWith(".md", StringComparison.Ordinal)) - lookup = lookup[..^3]; - - Uri? match = null; - foreach (var prefix in TableOfContentsPrefixes) - { - if (!lookup.StartsWith(prefix, StringComparison.Ordinal)) - continue; - match = new Uri(prefix); - break; + return new Uri(_baseUri, finalPath); } - if (match is null || !_topLevelMappings.TryGetValue(match, out var toc)) - { - var fallBack = new Uri(lookup.ToString()); - return [$"{fallBack.Host}/{fallBack.AbsolutePath.Trim('/')}"]; - } - path = MarkdownPathToUrlPath(path); + // No mapping found - use path as-is with the optional environment prefix + var fallbackPath = !string.IsNullOrEmpty(_pathPrefix) ? $"{_pathPrefix}/{path.TrimStart('/')}" : path; + return new Uri(_baseUri, fallbackPath); + } - var originalPath = Path.Combine(match.Host, match.AbsolutePath.Trim('/')).TrimStart('/'); - var relativePathSpan = path.AsSpan(); - var newRelativePath = relativePathSpan.StartsWith(originalPath, StringComparison.Ordinal) - ? relativePathSpan.Slice(originalPath.Length).TrimStart('/').ToString() - : relativePathSpan.TrimStart(originalPath).TrimStart('/').ToString(); + /// + /// Finds the best (longest) matching navigation mapping for a cross-link URI. + /// Uses longest-prefix matching to handle nested sources. + /// + /// + /// If navigation has: + /// - elasticsearch://reference → docs/elasticsearch/reference + /// - elasticsearch://reference/query-dsl → docs/elasticsearch/reference/query-dsl + /// + /// For "elasticsearch://reference/query-dsl/bool-query", we match the longer (more specific) mapping. + /// + private NavigationTocMapping? FindBestMatchForSource(Uri crossLinkUri) + { + NavigationTocMapping? bestMatch = null; + var bestMatchLength = -1; - var tokens = newRelativePath.Split('/', StringSplitOptions.RemoveEmptyEntries); - var paths = new List(); - var p = ""; - for (var index = 0; index < tokens.Length; index++) + // Build the full source path from the cross-link URI + var crossLinkSource = $"{crossLinkUri.Scheme}://{crossLinkUri.Host}/{crossLinkUri.AbsolutePath.TrimStart('/')}".TrimEnd('/'); + + foreach (var mapping in _navigationMappings.Values) { - p += tokens[index] + '/'; - paths.Add(p); + // Build the mapping's source as a string for comparison + var mappingSource = $"{mapping.Source.Scheme}://{mapping.Source.Host}/{mapping.Source.AbsolutePath.TrimStart('/')}".TrimEnd('/'); + + // Check if the cross-link starts with this mapping's source + if (crossLinkSource.StartsWith(mappingSource, StringComparison.Ordinal)) + { + // Keep the longest (most specific) match + if (mappingSource.Length > bestMatchLength) + { + bestMatch = mapping; + bestMatchLength = mappingSource.Length; + } + } } - return paths - .Select(i => $"{toc.SourcePathPrefix}/{i.TrimStart('/')}") - .Concat([$"{toc.SourcePathPrefix}/"]) - .ToArray(); + return bestMatch; } - private string GetSubPathPrefix(Uri crossLinkUri, ref string path) + /// + /// Resolves a cross-link URI to all its sub-paths for validation purposes. + /// Used by NavigationPrefixChecker to detect path collisions. + /// + /// The cross-link URI to resolve + /// The relative path within the repository + /// Array of URL path prefixes for collision detection + public string[] ResolveToSubPaths(Uri crossLinkUri, string path) { - var lookup = crossLinkUri.ToString().AsSpan(); - if (lookup.EndsWith(".md", StringComparison.Ordinal)) - lookup = lookup[..^3]; - - // the temporary fix only spotted two instances of this: - // Error: Unable to find defined toc for url: docs-content:///manage-data/ingest/transform-enrich/set-up-an-enrich-processor.md - // Error: Unable to find defined toc for url: kibana:///reference/configuration-reference.md - if (lookup.IndexOf(":///") >= 0) - lookup = lookup.ToString().Replace(":///", "://").AsSpan(); - - Uri? match = null; - foreach (var prefix in TableOfContentsPrefixes) + // Find the navigation mapping + var mapping = FindBestMatchForSource(crossLinkUri); + + if (mapping == null) + return []; + + // Get the source prefix to calculate the relative path + var sourcePrefix = $"{mapping.Source.Host}/{mapping.Source.AbsolutePath.TrimStart('/')}".Trim('/'); + var remainingPath = path; + + if (!string.IsNullOrEmpty(sourcePrefix) && path.StartsWith(sourcePrefix, StringComparison.Ordinal)) { - if (!lookup.StartsWith(prefix, StringComparison.Ordinal)) - continue; - match = new Uri(prefix); - break; + remainingPath = path.Length > sourcePrefix.Length + ? path.Substring(sourcePrefix.Length).TrimStart('/') + : string.Empty; } - if (match is null || !_topLevelMappings.TryGetValue(match, out var toc)) - return string.Empty; + // Build all sub-paths for this URL path + // For example, "reference/query-dsl/bool-query" generates: + // - "reference/" + // - "reference/query-dsl/" + // - "reference/query-dsl/bool-query/" + var urlPath = MarkdownPathToUrlPath(remainingPath); + var tokens = urlPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + var paths = new List(); + var accumulated = ""; + for (var index = 0; index < tokens.Length; index++) + { + accumulated += tokens[index] + '/'; + paths.Add($"{mapping.SourcePathPrefix}/{accumulated.TrimStart('/')}"); + } - var originalPath = Path.Combine(match.Host, match.AbsolutePath.Trim('/')); - if (originalPath == toc.SourcePathPrefix) - return string.Empty; + // Add the base path_prefix itself + paths.Add($"{mapping.SourcePathPrefix}/"); - var newRelativePath = path.AsSpan().GetTrimmedRelativePath(originalPath); - path = Path.Combine(toc.SourcePathPrefix, newRelativePath); + return paths.ToArray(); + } - return string.Empty; + /// + /// Converts a markdown file path to a URL path by removing .md extension and /index suffixes. + /// + public static string MarkdownPathToUrlPath(string path) + { + if (path.EndsWith("/index.md", StringComparison.OrdinalIgnoreCase)) + path = path[..^8]; + if (path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + path = path[..^3]; + return path; } } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs index edfa03b44..3b7d20ded 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs @@ -7,7 +7,6 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; using Microsoft.Extensions.Logging; namespace Elastic.Documentation.Assembler.Navigation; @@ -27,7 +26,6 @@ public AssemblerDocumentationSet( AssembleContext context, Checkout checkout, ICrossLinkResolver crossLinkResolver, - TableOfContentsTreeCollector treeCollector, IConfigurationContext configurationContext, IReadOnlySet availableExporters ) @@ -77,6 +75,6 @@ IReadOnlySet availableExporters }; BuildContext = buildContext; - DocumentationSet = new DocumentationSet(buildContext, logFactory, crossLinkResolver, treeCollector); + DocumentationSet = new DocumentationSet(buildContext, logFactory, crossLinkResolver); } } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigation.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigation.cs deleted file mode 100644 index e0ba7e920..000000000 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigation.cs +++ /dev/null @@ -1,211 +0,0 @@ -// 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.Collections.Frozen; -using System.Collections.Immutable; -using Elastic.Documentation.Configuration.Assembler; -using Elastic.Documentation.Configuration.Navigation; -using Elastic.Documentation.Configuration.TableOfContents; -using Elastic.Documentation.Site.Navigation; -using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; - -namespace Elastic.Documentation.Assembler.Navigation; - -public record GlobalNavigation : IPositionalNavigation -{ - private readonly AssembleSources _assembleSources; - private readonly GlobalNavigationFile _navigationFile; - - public IReadOnlyCollection NavigationItems { get; } - - public IReadOnlyCollection TopLevelItems { get; } - - public IReadOnlyDictionary NavigationLookup { get; } - - public FrozenDictionary MarkdownNavigationLookup { get; } - - public FrozenDictionary NavigationIndexedByOrder { get; } - - private ImmutableHashSet Phantoms { get; } - - private TableOfContentsTree RootContentTree { get; } - - public GlobalNavigation(AssembleSources assembleSources, GlobalNavigationFile navigationFile) - { - _assembleSources = assembleSources; - _navigationFile = navigationFile; - - // the root files of `docs-content://` are special they contain several special pages such as 404, archive, versions etc. - // we inject them forcefully here - var source = new Uri($"{NarrativeRepository.RepositoryName}://"); - RootContentTree = assembleSources.TreeCollector.TryGetTableOfContentsTree(source, out var docsContentTree) - ? docsContentTree - : throw new Exception($"Could not locate: {source} as root of global navigation."); - Phantoms = [.. navigationFile.Phantoms.Select(p => p.Source)]; - NavigationItems = BuildNavigation(navigationFile.TableOfContents, 0); - - var navigationIndex = 0; - var allNavigationItems = new HashSet(); - UpdateParent(allNavigationItems, NavigationItems, null, null); - UpdateNavigationIndex(NavigationItems, ref navigationIndex); - TopLevelItems = NavigationItems.OfType().Where(t => !t.Hidden).ToList(); - NavigationLookup = TopLevelItems.ToDictionary(kv => kv.Source, kv => kv); - - NavigationIndexedByOrder = allNavigationItems.ToDictionary(i => i.NavigationIndex, i => i).ToFrozenDictionary(); - - MarkdownNavigationLookup = NavigationItems - .SelectMany(DocumentationSet.Pairs) - .ToDictionary(kv => kv.Item1, kv => kv.Item2) - .ToFrozenDictionary(); - - } - - private void UpdateParent( - HashSet allNavigationItems, - IReadOnlyCollection navigationItems, - INodeNavigationItem? parent, - IRootNavigationItem? topLevelNavigation - ) - { - if (parent is IRootNavigationItem tree) - topLevelNavigation ??= tree; - foreach (var item in navigationItems) - { - switch (item) - { - case FileNavigationItem fileNavigationItem: - if (parent is not null) - fileNavigationItem.Parent = parent; - if (topLevelNavigation is not null) - fileNavigationItem.Model.NavigationRoot = topLevelNavigation; - _ = allNavigationItems.Add(fileNavigationItem); - break; - case CrossLinkNavigationItem crossLinkNavigationItem: - if (parent is not null) - crossLinkNavigationItem.Parent = parent; - _ = allNavigationItems.Add(crossLinkNavigationItem); - break; - case DocumentationGroup documentationGroup: - if (parent is not null) - documentationGroup.Parent = parent; - if (topLevelNavigation is not null) - documentationGroup.Index.NavigationRoot = topLevelNavigation; - _ = allNavigationItems.Add(documentationGroup); - UpdateParent(allNavigationItems, documentationGroup.NavigationItems, documentationGroup, topLevelNavigation); - break; - default: - _navigationFile.EmitError($"{nameof(GlobalNavigation)}.{nameof(UpdateParent)}: Unhandled navigation item type: {item.GetType()}"); - break; - } - } - } - - - private void UpdateNavigationIndex(IReadOnlyCollection navigationItems, ref int navigationIndex) - { - foreach (var item in navigationItems) - { - switch (item) - { - case FileNavigationItem fileNavigationItem: - var fileIndex = Interlocked.Increment(ref navigationIndex); - fileNavigationItem.NavigationIndex = fileIndex; - break; - case DocumentationGroup documentationGroup: - var groupIndex = Interlocked.Increment(ref navigationIndex); - documentationGroup.NavigationIndex = groupIndex; - UpdateNavigationIndex(documentationGroup.NavigationItems, ref navigationIndex); - break; - case CrossLinkNavigationItem crossLinkNavigationItem: - var crossLinkIndex = Interlocked.Increment(ref navigationIndex); - crossLinkNavigationItem.NavigationIndex = crossLinkIndex; - break; - default: - _navigationFile.EmitError($"{nameof(GlobalNavigation)}.{nameof(UpdateNavigationIndex)}: Unhandled navigation item type: {item.GetType()}"); - break; - } - } - } - - private IReadOnlyCollection BuildNavigation(IReadOnlyCollection references, int depth) - { - var list = new List(); - foreach (var tocReference in references) - { - if (!_assembleSources.TreeCollector.TryGetTableOfContentsTree(tocReference.Source, out var tree)) - { - _navigationFile.EmitError($"{tocReference.Source} does not define a toc.yml or docset.yml file"); - continue; - } - - var tocChildren = tocReference.Children.OfType().ToArray(); - var tocNavigationItems = BuildNavigation(tocChildren, depth + 1); - - if (depth == 0 && tree.Parent != RootContentTree) - { - tree.Parent = RootContentTree; - tree.Index.NavigationRoot = tree; - } - - var configuredNavigationItems = - depth == 0 - ? tocNavigationItems.Concat(tree.NavigationItems) - : tree.NavigationItems.Concat(tocNavigationItems); - - var cleanNavigationItems = new List(); - var seenSources = new HashSet(); - foreach (var item in configuredNavigationItems) - { - if (item is not TableOfContentsTree tocNav) - { - cleanNavigationItems.Add(item); - continue; - } - - if (seenSources.Contains(tocNav.Source)) - continue; - - if (Phantoms.Contains(tree.NavigationSource)) - continue; - - // toc is not part of `navigation.yml` - if (!_assembleSources.NavigationTocMappings.TryGetValue(tocNav.Source, out var mapping)) - continue; - - // this TOC was moved in navigation.yml to a new parent and should not be part of the current navigation items - if (mapping.ParentSource != tree.Source) - continue; - - _ = seenSources.Add(tocNav.Source); - cleanNavigationItems.Add(item); - item.Parent = tree; - } - - tree.NavigationItems = cleanNavigationItems.ToArray(); - list.Add(tree); - - if (tocReference.IsPhantom) - tree.Hidden = true; - } - - if (depth != 0) - return list.ToArray().AsReadOnly(); - - // the root files of `docs-content://` are special they contain several special pages such as 404, archive, versions etc. - // we inject them forcefully here - if (!RootContentTree.NavigationItems.OfType().Any()) - _navigationFile.EmitError($"Could not inject root file navigation items from: {RootContentTree.Source}."); - else - { - var filesAtRoot = RootContentTree.NavigationItems.OfType().ToArray(); - list.AddRange(filesAtRoot); - // ensure index exist as a single item rather than injecting the whole tree (which already exists in the returned list) - var index = new FileNavigationItem(RootContentTree.Index, RootContentTree, RootContentTree.Hidden); - list.Add(index); - } - - return list.ToArray().AsReadOnly(); - } -} diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs index a79fd8734..8b63bda52 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs @@ -4,54 +4,72 @@ using System.Collections.Concurrent; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Site.Navigation; -using Elastic.Markdown.IO.Navigation; using Microsoft.Extensions.Logging; namespace Elastic.Documentation.Assembler.Navigation; -public class GlobalNavigationHtmlWriter(ILoggerFactory logFactory, GlobalNavigation globalNavigation, IDiagnosticsCollector collector) : INavigationHtmlWriter +public class GlobalNavigationHtmlWriter(ILoggerFactory logFactory, SiteNavigation globalNavigation, IDiagnosticsCollector collector) : INavigationHtmlWriter, IDisposable { private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly SemaphoreSlim _semaphore = new(1, 1); private readonly ConcurrentDictionary<(string, int), string> _renderedNavigationCache = []; - public async Task RenderNavigation(IRootNavigationItem currentRootNavigation, int maxLevel, Cancel ctx = default) + public async Task RenderNavigation( + IRootNavigationItem currentRootNavigation, +#pragma warning disable IDE0060 + INavigationItem currentNavigationItem, // temporary https://github.com/elastic/docs-content/pull/3730 +#pragma warning restore IDE0060 + int maxLevel, + Cancel ctx = default + ) { - if (currentRootNavigation.Parent is not null && currentRootNavigation.Parent.Depth != 0) - collector.EmitGlobalError($"Passed root is not actually a top level navigation item {currentRootNavigation.NavigationTitle} ({currentRootNavigation.Id}) in {currentRootNavigation.Url}"); + if (currentRootNavigation is SiteNavigation) + return NavigationRenderResult.Empty; + + if (currentRootNavigation.Parent is null or not SiteNavigation) + collector.EmitGlobalError($"Passed root is not actually a top level navigation item {currentRootNavigation.NavigationTitle} ({currentRootNavigation.Id}) in {currentRootNavigation.Url}, trying to render: {currentNavigationItem.Url}"); if (_renderedNavigationCache.TryGetValue((currentRootNavigation.Id, maxLevel), out var html)) + return new NavigationRenderResult { Html = html, Id = currentRootNavigation.Id }; + + if (currentRootNavigation is not INodeNavigationItem group) + return NavigationRenderResult.Empty; + + await _semaphore.WaitAsync(ctx); + + try { + if (_renderedNavigationCache.TryGetValue((currentRootNavigation.Id, maxLevel), out html)) + return new NavigationRenderResult { Html = html, Id = currentRootNavigation.Id }; + + _logger.LogInformation("Rendering navigation for {NavigationTitle} ({Id})", currentRootNavigation.NavigationTitle, currentRootNavigation.Id); + + var model = CreateNavigationModel(group, maxLevel); + html = await ((INavigationHtmlWriter)this).Render(model, ctx); + _renderedNavigationCache[(currentRootNavigation.Id, maxLevel)] = html; return new NavigationRenderResult { Html = html, Id = currentRootNavigation.Id }; } - - _logger.LogInformation("Rendering navigation for {NavigationTitle} ({Id})", currentRootNavigation.NavigationTitle, currentRootNavigation.Id); - - if (currentRootNavigation is not DocumentationGroup group) - return NavigationRenderResult.Empty; - - var model = CreateNavigationModel(group, maxLevel); - html = await ((INavigationHtmlWriter)this).Render(model, ctx); - _renderedNavigationCache[(currentRootNavigation.Id, maxLevel)] = html; - return new NavigationRenderResult + finally { - Html = html, - Id = currentRootNavigation.Id - }; + _ = _semaphore.Release(); + } } - private NavigationViewModel CreateNavigationModel(DocumentationGroup group, int maxLevel) + private NavigationViewModel CreateNavigationModel(INodeNavigationItem group, int maxLevel) { var topLevelItems = globalNavigation.TopLevelItems; return new NavigationViewModel { - Title = group.Index.NavigationTitle, - TitleUrl = group.Index.Url, + Title = group.NavigationTitle, + TitleUrl = group.Url, Tree = group, IsPrimaryNavEnabled = true, IsUsingNavigationDropdown = true, @@ -60,4 +78,10 @@ private NavigationViewModel CreateNavigationModel(DocumentationGroup group, int MaxLevel = maxLevel }; } + + public void Dispose() + { + _semaphore.Dispose(); + GC.SuppressFinalize(this); + } } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationPathProvider.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationPathProvider.cs index 0332d4bc5..a38da7c55 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationPathProvider.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationPathProvider.cs @@ -4,8 +4,8 @@ using System.Collections.Immutable; using System.IO.Abstractions; -using Elastic.Documentation.Configuration.Navigation; using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Assembler; using Elastic.Markdown; using Elastic.Markdown.Extensions.DetectionRules; using Elastic.Markdown.IO; @@ -20,7 +20,7 @@ public record GlobalNavigationPathProvider : IDocumentationFileOutputProvider public ImmutableSortedSet TableOfContentsPrefixes { get; } private ImmutableSortedSet PhantomPrefixes { get; } - public GlobalNavigationPathProvider(GlobalNavigationFile navigationFile, AssembleSources assembleSources, AssembleContext context) + public GlobalNavigationPathProvider(SiteNavigation navigation, AssembleSources assembleSources, AssembleContext context) { _assembleSources = assembleSources; _context = context; @@ -35,7 +35,7 @@ public GlobalNavigationPathProvider(GlobalNavigationFile navigationFile, Assembl .OrderByDescending(v => v.Length) ]; - PhantomPrefixes = [..navigationFile.Phantoms + PhantomPrefixes = [..navigation.Phantoms .Select(p => { var source = p.Source.ToString(); @@ -52,8 +52,6 @@ public GlobalNavigationPathProvider(GlobalNavigationFile navigationFile, Assembl if (relativePath.StartsWith("_static/", StringComparison.Ordinal)) return defaultOutputFile; - - var repositoryName = documentationSet.Context.Git.RepositoryName; var outputDirectory = documentationSet.OutputDirectory; var fs = defaultOutputFile.FileSystem; diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs index 6e25b83a2..6891281f7 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs @@ -5,7 +5,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; -using Elastic.Documentation.Configuration.Navigation; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -24,8 +24,12 @@ public async Task Validate(IDiagnosticsCollector collector, Cancel ctx) var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); var namespaceChecker = new NavigationPrefixChecker(logFactory, assembleContext); + var navigationFileInfo = assembleContext.ConfigurationFileProvider.NavigationFile; + var navigationYaml = fileSystem.File.ReadAllText(navigationFileInfo.FullName); + var siteNavigationFile = SiteNavigationFile.Deserialize(navigationYaml); + // this validates all path prefixes are unique, early exit if duplicates are detected - if (!GlobalNavigationFile.ValidatePathPrefixes(collector, assembleContext.ConfigurationFileProvider, configuration) || collector.Errors > 0) + if (!SiteNavigationFile.ValidatePathPrefixes(collector, siteNavigationFile, navigationFileInfo) || collector.Errors > 0) return false; await namespaceChecker.CheckAllPublishedLinks(assembleContext.Collector, ctx); diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs b/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs index e269cb7d3..2d735cb63 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/LlmsNavigationEnhancer.cs @@ -2,15 +2,13 @@ // 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; using System.Globalization; -using System.Linq; using System.Text; -using Elastic.Documentation.Assembler; -using Elastic.Documentation.Assembler.Navigation; -using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Assembler; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Leaf; using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; using Elastic.Markdown.Myst.Renderers.LlmMarkdown; namespace Elastic.Documentation.Assembler.Navigation; @@ -20,7 +18,7 @@ namespace Elastic.Documentation.Assembler.Navigation; /// public class LlmsNavigationEnhancer { - public string GenerateNavigationSections(GlobalNavigation navigation, Uri canonicalBaseUrl) + public string GenerateNavigationSections(SiteNavigation navigation, Uri canonicalBaseUrl) { var content = new StringBuilder(); @@ -29,7 +27,7 @@ public string GenerateNavigationSections(GlobalNavigation navigation, Uri canoni foreach (var topLevelItem in topLevelItems) { - if (topLevelItem is not DocumentationGroup group) + if (topLevelItem is not { } group) continue; // Create H2 section for the category - use H1 title if available, fallback to navigation title @@ -40,7 +38,7 @@ public string GenerateNavigationSections(GlobalNavigation navigation, Uri canoni // Get first-level children var firstLevelChildren = GetFirstLevelChildren(group); - if (firstLevelChildren.Any()) + if (firstLevelChildren.Count != 0) { foreach (var child in firstLevelChildren) { @@ -60,25 +58,21 @@ public string GenerateNavigationSections(GlobalNavigation navigation, Uri canoni } - private static IEnumerable GetFirstLevelChildren(DocumentationGroup group) => - group.NavigationItems.Where(i => !i.Hidden); + private static IReadOnlyCollection GetFirstLevelChildren(INodeNavigationItem group) => + group.NavigationItems.Where(i => !i.Hidden).ToArray(); /// /// Gets the best title for a navigation item, preferring H1 content over navigation title /// private static string GetBestTitle(INavigationItem navigationItem) => navigationItem switch { - // For file navigation items, prefer the H1 title from the markdown content - FileNavigationItem fileItem when !string.IsNullOrEmpty(fileItem.Model.Title) - => fileItem.Model.Title, - FileNavigationItem fileItem - => fileItem.NavigationTitle, - - // For documentation groups, prefer the H1 title from the index file - DocumentationGroup group when !string.IsNullOrEmpty(group.Index?.Title) - => group.Index.Title, - DocumentationGroup group - => group.NavigationTitle, + // For file navigation items, prefer the H1 title from the Markdown content + ILeafNavigationItem markdownNavigation => + markdownNavigation.Model.Title ?? markdownNavigation.NavigationTitle, + + // For documentation groups, try to get the full title of the index + INodeNavigationItem markdownNodeNavigation => + markdownNodeNavigation.Index.Model.Title ?? markdownNodeNavigation.NavigationTitle, // For other navigation item types, use the navigation title _ => navigationItem.NavigationTitle @@ -86,26 +80,26 @@ DocumentationGroup group private static string? GetDescription(INavigationItem navigationItem) => navigationItem switch { + // Cross-repository links don't have descriptions in frontmatter + ILeafNavigationItem => null, + // For file navigation items, extract from frontmatter - FileNavigationItem fileItem when fileItem.Model is MarkdownFile markdownFile - => markdownFile.YamlFrontMatter?.Description, + ILeafNavigationItem markdownNavigation => + markdownNavigation.Model.YamlFrontMatter?.Description, // For documentation groups, try to get from index file - DocumentationGroup group when group.Index is MarkdownFile indexFile - => indexFile.YamlFrontMatter?.Description, + INodeNavigationItem markdownNodeNavigation => + markdownNodeNavigation.Index.Model.YamlFrontMatter?.Description, - // For table of contents trees (inherits from DocumentationGroup, but handled explicitly) - TableOfContentsTree tocTree when tocTree.Index is MarkdownFile indexFile - => indexFile.YamlFrontMatter?.Description, - - // Cross-repository links don't have descriptions in frontmatter - CrossLinkNavigationItem => null, + // we only know about MarkdownFiles for now + ILeafNavigationItem => null, + INodeNavigationItem => null, // API-related navigation items (these don't have markdown frontmatter) // Check by namespace to avoid direct assembly references - INavigationItem item when item.GetType().FullName?.StartsWith("Elastic.ApiExplorer.", StringComparison.Ordinal) == true => null, + { } item when item.GetType().FullName?.StartsWith("Elastic.ApiExplorer.", StringComparison.Ordinal) == true => null, // Throw exception for any unhandled navigation item types - _ => throw new InvalidOperationException($"Unhandled navigation item type: {navigationItem.GetType().FullName}") + _ => throw new InvalidOperationException($"{nameof(LlmsNavigationEnhancer)}.{nameof(GetDescription)}: Unhandled navigation item type: {navigationItem.GetType().FullName}") }; } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/NavigationPrefixChecker.cs b/src/services/Elastic.Documentation.Assembler/Navigation/NavigationPrefixChecker.cs index 65c773a99..e8ce20ce1 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/NavigationPrefixChecker.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/NavigationPrefixChecker.cs @@ -5,7 +5,7 @@ using System.Collections.Immutable; using Elastic.Documentation.Assembler.Links; using Elastic.Documentation.Configuration; -using Elastic.Documentation.Configuration.Navigation; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links; @@ -41,7 +41,11 @@ public class NavigationPrefixChecker /// public NavigationPrefixChecker(ILoggerFactory logFactory, AssembleContext context) { - _phantoms = GlobalNavigationFile.GetPhantomPrefixes(context.Collector, context.ConfigurationFileProvider, context.Configuration); + var navigationFileInfo = context.ConfigurationFileProvider.NavigationFile; + var navigationYaml = context.ReadFileSystem.File.ReadAllText(navigationFileInfo.FullName); + var siteNavigationFile = SiteNavigationFile.Deserialize(navigationYaml); + + _phantoms = SiteNavigationFile.GetPhantomPrefixes(siteNavigationFile); _repositories = context.Configuration.AvailableRepositories.Values .Select(r => r.Name) @@ -88,16 +92,36 @@ private async Task FetchAndValidateCrossLinks(IDiagnosticsCollector collector, s var dictionary = new Dictionary(); if (!string.IsNullOrEmpty(updateRepository) && updateReference is not null) crossLinks = crossLinkResolver.UpdateLinkReference(updateRepository, updateReference); + + var skippedPhantoms = 0; foreach (var (repository, linkReference) in crossLinks.LinkReferences) { if (!_repositories.Contains(repository)) continue; + _logger.LogInformation("Validating '{Repository}'", repository); // Todo publish all relative folders as part of the link reference // That way we don't need to iterate over all links and find all permutations of their relative paths foreach (var (relativeLink, _) in linkReference.Links) { - var navigationPaths = _uriResolver.ResolveToSubPaths(new Uri($"{repository}://{relativeLink}"), relativeLink); + var crossLink = new Uri($"{repository}://{relativeLink.TrimEnd('/')}"); + var navigationPaths = _uriResolver.ResolveToSubPaths(crossLink, relativeLink); + if (navigationPaths.Length == 0) + { + var path = relativeLink.Split('/').SkipLast(1); + var pathUri = new Uri($"{repository}://{string.Join('/', path)}"); + + var baseOfAPhantom = _phantoms.Any(p => p == pathUri); + if (baseOfAPhantom) + { + skippedPhantoms++; + if (skippedPhantoms > _phantoms.Count * 3) + collector.EmitError(repository, $"Too many items are being marked as part of a phantom this looks like a bug. ({skippedPhantoms})"); + continue; + } + collector.EmitError(repository, $"'Can not validate '{crossLink}' it's not declared in any link reference nor is it a phantom"); + continue; + } foreach (var navigationPath in navigationPaths) { if (dictionary.TryGetValue(navigationPath, out var seen)) diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 82f36367b..3c8442fba 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -193,12 +193,15 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) slug = slug.Replace('/', Path.DirectorySeparatorChar); + slug = slug.TrimEnd('/'); var s = Path.GetExtension(slug) == string.Empty ? Path.Combine(slug, "index.md") : slug; + var fp = new FilePath(s, generator.DocumentationSet.SourceDirectory); - if (!generator.DocumentationSet.FlatMappedFiles.TryGetValue(s, out var documentationFile)) + if (!generator.DocumentationSet.Files.TryGetValue(fp, out var documentationFile)) { s = Path.GetExtension(slug) == string.Empty ? slug + ".md" : s.Replace($"{Path.DirectorySeparatorChar}index.md", ".md"); - if (!generator.DocumentationSet.FlatMappedFiles.TryGetValue(s, out documentationFile)) + fp = new FilePath(s, generator.DocumentationSet.SourceDirectory); + if (!generator.DocumentationSet.Files.TryGetValue(fp, out documentationFile)) { foreach (var extension in holder.Generator.DocumentationSet.EnabledExtensions) { @@ -226,9 +229,10 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta return Results.File(image.SourceFile.FullName, image.MimeType); default: if (s == "index.md") - return Results.Redirect(generator.DocumentationSet.MarkdownFiles.First().Url); + return Results.Redirect(generator.DocumentationSet.Navigation.Url); - if (!generator.DocumentationSet.FlatMappedFiles.TryGetValue("404.md", out var notFoundDocumentationFile)) + var fp404 = new FilePath("404.md", generator.DocumentationSet.SourceDirectory); + if (!generator.DocumentationSet.Files.TryGetValue(fp404, out var notFoundDocumentationFile)) return Results.NotFound(); if (Path.GetExtension(s) is "" or not ".md") diff --git a/tests-integration/Directory.Build.props b/tests-integration/Directory.Build.props index 7f8cf2d64..b228e583a 100644 --- a/tests-integration/Directory.Build.props +++ b/tests-integration/Directory.Build.props @@ -6,7 +6,7 @@ false false true - CA1822;IDE0058 + CA1822;IDE0058;xUnit1004 false true diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/AssembleFixture.cs b/tests-integration/Elastic.Assembler.IntegrationTests/AssembleFixture.cs index bda41a813..20a11747f 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/AssembleFixture.cs +++ b/tests-integration/Elastic.Assembler.IntegrationTests/AssembleFixture.cs @@ -6,7 +6,6 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; using Elastic.Documentation.ServiceDefaults; -using FluentAssertions; using InMemLogger; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -111,29 +110,3 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } } - -public class ServeStaticTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime -{ - [Fact] - public async Task AssertRequestToRootReturnsData() - { - var client = fixture.DistributedApplication.CreateHttpClient(AssemblerServe, "http"); - var root = await client.GetStringAsync("/", TestContext.Current.CancellationToken); - _ = root.Should().NotBeNullOrEmpty(); - } - - - /// - public ValueTask DisposeAsync() - { - GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) - return default; - foreach (var resource in fixture.InMemoryLogger.RecordedLogs) - output.WriteLine(resource.Message); - return default; - } - - /// - public ValueTask InitializeAsync() => default; -} diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj b/tests-integration/Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj index dabf034b7..73d9c4b12 100644 --- a/tests-integration/Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj +++ b/tests-integration/Elastic.Assembler.IntegrationTests/Elastic.Assembler.IntegrationTests.csproj @@ -13,6 +13,7 @@ + diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/GlobalNavigationTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/GlobalNavigationTests.cs deleted file mode 100644 index b7e117a4f..000000000 --- a/tests-integration/Elastic.Assembler.IntegrationTests/GlobalNavigationTests.cs +++ /dev/null @@ -1,327 +0,0 @@ -// 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; -using Elastic.Documentation; -using Elastic.Documentation.Assembler; -using Elastic.Documentation.Assembler.Navigation; -using Elastic.Documentation.Assembler.Sourcing; -using Elastic.Documentation.Configuration; -using Elastic.Documentation.Configuration.Assembler; -using Elastic.Documentation.Configuration.Navigation; -using Elastic.Documentation.Diagnostics; -using Elastic.Documentation.Site.Navigation; -using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Elastic.Assembler.IntegrationTests; - -public class GlobalNavigationPathProviderTests : IAsyncLifetime -{ - private readonly DocumentationFixture _fixture; - private readonly ITestOutputHelper _output; - private DiagnosticsCollector Collector { get; } - private AssembleContext Context { get; } - private FileSystem FileSystem { get; } - private IDirectoryInfo CheckoutDirectory { get; } - - private bool HasCheckouts() => CheckoutDirectory.Exists; - - public GlobalNavigationPathProviderTests(DocumentationFixture fixture, ITestOutputHelper output) - { - _fixture = fixture; - _output = output; - FileSystem = new FileSystem(); - var checkoutDirectory = FileSystem.DirectoryInfo.New( - FileSystem.Path.Combine(Paths.GetSolutionDirectory()!.FullName, ".artifacts", "checkouts") - ); - CheckoutDirectory = checkoutDirectory.Exists - ? checkoutDirectory.GetDirectories().FirstOrDefault(d => d.Name is "next" or "current") ?? checkoutDirectory - : checkoutDirectory; - Collector = new DiagnosticsCollector([]); - var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); - var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - Context = new AssembleContext(config, configurationContext, "dev", Collector, FileSystem, FileSystem, CheckoutDirectory.FullName, null); - } - - private Checkout CreateCheckout(IFileSystem fs, Repository repository) - { - var name = repository.Name; - var path = repository.Path is { } p - ? fs.DirectoryInfo.New(p) - : fs.DirectoryInfo.New(fs.Path.Combine(Path.Combine(CheckoutDirectory.FullName, name))); - return new Checkout - { - Repository = new Repository - { - Name = name, - Origin = $"elastic/{name}" - }, - HeadReference = Guid.NewGuid().ToString(), - Directory = path - }; - } - - private async Task Setup() - { - _ = Collector.StartAsync(TestContext.Current.CancellationToken); - - var repos = Context.Configuration.AvailableRepositories - .Where(kv => !kv.Value.Skip) - .Select(kv => kv.Value) - .ToArray(); - var checkouts = repos.Select(r => CreateCheckout(FileSystem, r)).ToArray(); - var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var assembleSources = await AssembleSources.AssembleAsync( - NullLoggerFactory.Instance, Context, checkouts, configurationContext, ExportOptions.Default, TestContext.Current.CancellationToken - ); - return assembleSources; - } - - [Fact] - public async Task ReadAllPathPrefixes() - { - Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); - - await using var collector = new DiagnosticsCollector([]); - - var fileSystem = new FileSystem(); - var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var context = new AssembleContext(config, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); - - var pathPrefixes = GlobalNavigationFile.GetAllPathPrefixes(context.Collector, context.ConfigurationFileProvider, context.Configuration); - - pathPrefixes.Should().NotBeEmpty(); - pathPrefixes.Should().Contain(new Uri("eland://reference/elasticsearch/clients/eland/")); - } - - [Fact] - public async Task PathProvider() - { - Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); - - var assembleSources = await Setup(); - - var navigationFile = new GlobalNavigationFile(Context.Collector, Context.ConfigurationFileProvider, Context.Configuration, assembleSources.TocConfigurationMapping); - var pathProvider = new GlobalNavigationPathProvider(navigationFile, assembleSources, Context); - - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(new Uri("detection-rules://")); - pathProvider.TableOfContentsPrefixes.Should().Contain("detection-rules://"); - } - - - [Fact] - public async Task ParsesReferences() - { - Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); - - var expectedRoot = new Uri("docs-content://reference/"); - var expectedParent = new Uri("docs-content://reference/apm-agents/"); - var sut = new Uri("apm-agent-dotnet://reference/"); - var clients = new Uri("docs-content://reference/elasticsearch-clients/"); - var assembleSources = await Setup(); - - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(sut); - assembleSources.NavigationTocMappings[sut].TopLevelSource.Should().Be(expectedRoot); - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(expectedRoot); - assembleSources.NavigationTocMappings[sut].ParentSource.Should().Be(expectedParent); - - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(new Uri("detection-rules://")); - - var navigationFile = new GlobalNavigationFile(Context.Collector, Context.ConfigurationFileProvider, Context.Configuration, assembleSources.TocConfigurationMapping); - var referenceToc = navigationFile.TableOfContents.FirstOrDefault(t => t.Source == expectedRoot); - referenceToc.Should().NotBeNull(); - referenceToc.TocReferences.Should().NotContainKey(clients); - - var ingestTools = referenceToc.TocReferences[new Uri("docs-content://reference/ingestion-tools/")]; - ingestTools.Should().NotBeNull(); - - var apmReference = ingestTools.TocReferences[new Uri("docs-content://reference/apm/")]; - apmReference.Should().NotBeNull(); - - var agentsRef = apmReference.TocReferences[expectedParent]; - apmReference.Should().NotBeNull(); - - var agentsRefTocReference = agentsRef.TocReferences[sut]; - agentsRefTocReference.Should().NotBeNull(); - - var navigation = new GlobalNavigation(assembleSources, navigationFile); - var referenceNav = navigation.NavigationLookup[expectedRoot]; - navigation.NavigationItems.OfType() - .Should().HaveSameCount(navigation.NavigationLookup); - - referenceNav.Should().NotBeNull(); - var navigationLookup = referenceNav.NavigationItems.OfType().ToDictionary(i => i.Source, i => i); - navigationLookup.Should().NotContainKey(clients); - referenceNav.NavigationItems.OfType() - .Select(n => n.Source) - .Should().NotContain(clients); - referenceNav.NavigationItems.Should().HaveSameCount(navigationLookup); - - var ingestNav = navigationLookup[new Uri("docs-content://reference/ingestion-tools/")]; - ingestNav.Should().NotBeNull(); - var ingestLookup = ingestNav.NavigationItems.OfType().ToDictionary(i => i.Source, i => i); - ingestLookup.Should().NotContainKey(clients); - ingestNav.NavigationItems.OfType() - .Select(n => n.Source) - .Should().NotContain(clients); - - var apmNav = ingestLookup[new Uri("docs-content://reference/apm/")]; - apmNav.Should().NotBeNull(); - - var apmLookup = apmNav.NavigationItems.OfType().ToDictionary(i => i.Source, i => i); - var apmAgentsNav = apmLookup[expectedParent]; - apmAgentsNav.Should().NotBeNull(); - - var apmAgentLookup = apmAgentsNav.NavigationItems.OfType().ToDictionary(i => i.Source, i => i); - var dotnetAgentNav = apmAgentLookup[sut]; - dotnetAgentNav.Should().NotBeNull(); - - var resolved = navigation.NavigationItems; - resolved.Should().NotBeNull(); - - } - - - - [Fact] - public async Task ParsesGlobalNavigation() - { - Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); - - var expectedRoot = new Uri("docs-content://extend"); - var kibanaExtendMoniker = new Uri("kibana://extend/"); - - var assembleSources = await Setup(); - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(kibanaExtendMoniker); - assembleSources.NavigationTocMappings[kibanaExtendMoniker].TopLevelSource.Should().Be(expectedRoot); - assembleSources.NavigationTocMappings.Should().NotBeEmpty().And.ContainKey(new Uri("docs-content://reference/apm/")); - - var uri = new Uri("integrations://extend"); - assembleSources.TreeCollector.Should().NotBeNull(); - _ = assembleSources.TreeCollector.TryGetTableOfContentsTree(uri, out var tree); - tree.Should().NotBeNull(); - - _ = assembleSources.TreeCollector.TryGetTableOfContentsTree(new Uri("docs-content://reference/"), out tree); - tree.Should().NotBeNull(); - - assembleSources.AssembleSets.Should().NotBeEmpty(); - - assembleSources.TocConfigurationMapping.Should().NotBeEmpty().And.ContainKey(kibanaExtendMoniker); - var kibanaConfigMapping = assembleSources.TocConfigurationMapping[kibanaExtendMoniker]; - kibanaConfigMapping.Should().NotBeNull(); - kibanaConfigMapping.TableOfContentsConfiguration.Should().NotBeNull(); - assembleSources.TocConfigurationMapping[kibanaExtendMoniker].Should().NotBeNull(); - - var navigationFile = new GlobalNavigationFile(Context.Collector, Context.ConfigurationFileProvider, Context.Configuration, assembleSources.TocConfigurationMapping); - navigationFile.TableOfContents.Should().NotBeNull().And.NotBeEmpty(); - navigationFile.TableOfContents.Count.Should().BeLessThan(20); - - var navigation = new GlobalNavigation(assembleSources, navigationFile); - navigation.TopLevelItems.Count.Should().BeLessThan(20); - var resolved = navigation.NavigationItems; - resolved.Should().NotBeNull(); - - - IPositionalNavigation positionalNavigation = navigation; - - var addToHelm = positionalNavigation.MarkdownNavigationLookup.GetValueOrDefault("apm-k8s-attacher://reference/apm-webhook-add-helm-repo.md"); - addToHelm.Should().NotBeNull(); - var parentGroup = addToHelm.Parent as DocumentationGroup; - var parents = AssertHasParents(parentGroup, positionalNavigation, addToHelm); - - parents - .Select(p => p.Url).Should().ContainInOrder( - [ - "/docs/reference/apm/k8s-attacher/apm-get-started-webhook", - "/docs/reference/apm/k8s-attacher", - "/docs/reference/apm/observability/apm", - "/docs/reference/ingestion-tools/", - "/docs/reference/", - "/docs/" - ]); - - var getStartedIntro = positionalNavigation.MarkdownNavigationLookup.GetValueOrDefault("docs-content://get-started/introduction.md"); - getStartedIntro.Should().NotBeNull(); - parentGroup = getStartedIntro.Parent as DocumentationGroup; - _ = AssertHasParents(parentGroup, positionalNavigation, getStartedIntro); - - } - - private static INavigationItem[] AssertHasParents( - DocumentationGroup? parent, - IPositionalNavigation positionalNavigation, - INavigationItem item - ) - { - parent.Should().NotBeNull(); - parent.Index.Should().NotBeNull(); - var parents2 = positionalNavigation.GetParents(item); - var parents3 = positionalNavigation.GetParents(item); - var markdown = (item as FileNavigationItem)?.Model!; - var parents = positionalNavigation.GetParentsOfMarkdownFile(markdown); - - parents.Should().NotBeEmpty().And.HaveCount(parents2.Length).And.HaveCount(parents3.Length); - return parents; - } - - [Fact] - public async Task UriResolving() - { - Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); - - await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); - - var fs = new FileSystem(); - var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, fs, fs, null, null); - var repos = assembleContext.Configuration.AvailableRepositories - .Where(kv => !kv.Value.Skip) - .Select(kv => kv.Value) - .ToArray(); - var checkouts = repos.Select(r => CreateCheckout(fs, r)).ToArray(); - var assembleSources = await AssembleSources.AssembleAsync( - NullLoggerFactory.Instance, assembleContext, checkouts, configurationContext, ExportOptions.Default, TestContext.Current.CancellationToken - ); - - var navigationFile = new GlobalNavigationFile(Context.Collector, Context.ConfigurationFileProvider, Context.Configuration, assembleSources.TocConfigurationMapping); - navigationFile.TableOfContents.Should().NotBeNull().And.NotBeEmpty(); - - var uriResolver = assembleSources.UriResolver; - - // docs-content://reference/apm/something.md - url hasn't changed - var resolvedUri = uriResolver.Resolve(new Uri("docs-content://reference/apm/something.md"), "/reference/apm/something"); - resolvedUri.Should().Be("https://www.elastic.co/docs/reference/apm/something"); - - resolvedUri = uriResolver.Resolve(new Uri("apm-agent-nodejs://reference/instrumentation.md"), "/reference/instrumentation"); - resolvedUri.Should().Be("https://www.elastic.co/docs/reference/apm/agents/nodejs/instrumentation"); - - resolvedUri = uriResolver.Resolve(new Uri("apm-agent-dotnet://reference/a/file.md"), "/reference/a/file"); - resolvedUri.Should().Be("https://www.elastic.co/docs/reference/apm/agents/dotnet/a/file"); - - resolvedUri = uriResolver.Resolve(new Uri("elasticsearch-net://reference/b/file.md"), "/reference/b/file"); - resolvedUri.Should().Be("https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/b/file"); - - resolvedUri = uriResolver.Resolve(new Uri("elasticsearch://extend/c/file.md"), "/extend/c/file"); - resolvedUri.Should().Be("https://www.elastic.co/docs/extend/elasticsearch/c/file"); - } - - /// - public ValueTask DisposeAsync() - { - GC.SuppressFinalize(this); - if (TestContext.Current.TestState?.Result is TestResult.Passed) - return default; - foreach (var resource in _fixture.InMemoryLogger.RecordedLogs) - _output.WriteLine(resource.Message); - return default; - } - - /// - public ValueTask InitializeAsync() => default; -} diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs new file mode 100644 index 000000000..cced32841 --- /dev/null +++ b/tests-integration/Elastic.Assembler.IntegrationTests/NavigationBuildingTests.cs @@ -0,0 +1,180 @@ +// 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; +using System.IO.Abstractions.TestingHelpers; +using AngleSharp; +using Documentation.Builder; +using Elastic.Documentation; +using Elastic.Documentation.Assembler; +using Elastic.Documentation.Assembler.Sourcing; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Assembler; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Leaf; +using Elastic.Documentation.ServiceDefaults; +using Elastic.Documentation.Site.Navigation; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using RazorSlices; + +namespace Elastic.Assembler.IntegrationTests; + +public class NavigationBuildingTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime +{ + [Fact(Skip = "Disabling this since it can't run on CI, dig in why Assert.SkipWhen doesn't work")] + public async Task AssertRealNavigation() + { + //Skipping on CI since this relies on checking out private repositories + Assert.SkipWhen(Environment.GetEnvironmentVariable("CI") == "true", "Skipping in CI"); + string[] args = []; + var builder = Host.CreateApplicationBuilder() + .AddDocumentationServiceDefaults(ref args, (s, p) => + { + _ = s.AddSingleton(AssemblyConfiguration.Create(p)); + }) + .AddDocumentationToolingDefaults(); + var host = builder.Build(); + + var configurationContext = host.Services.GetRequiredService(); + + var assemblyConfiguration = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); + var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper!); + var fs = new FileSystem(); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, fs, new MockFileSystem(), null, null); + var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); + var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); + var checkoutResult = cloner.GetAll(); + var checkouts = checkoutResult.Checkouts.ToArray(); + _ = collector.StartAsync(TestContext.Current.CancellationToken); + + if (checkouts.Length == 0) + throw new Exception("No checkouts found"); + + var ctx = TestContext.Current.CancellationToken; + var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, new HashSet(), ctx); + + var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; + var siteNavigationFile = SiteNavigationFile.Deserialize(await fs.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); + var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray(); + var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets, assembleContext.Environment.PathPrefix); + + RecurseNav(navigation); + + /* + foreach (var (source, toc) in navigation.Nodes) + { + var root = toc.NavigationRoot; + root.Should().NotBeNull(); + if (root.Parent is null or not SiteNavigation) + { + } + root.Parent.Should().BeOfType(); + }*/ + + var slice = _TocTree.Create(new NavigationViewModel + { + Title = "X", + IsGlobalAssemblyBuild = true, + IsPrimaryNavEnabled = true, + Tree = navigation, + TopLevelItems = navigation.TopLevelItems, + TitleUrl = navigation.Index.Url, + IsUsingNavigationDropdown = true, + MaxLevel = -1 + }); + var html = await slice.RenderAsync(cancellationToken: ctx); + var context = BrowsingContext.New(); + var document = await context.OpenAsync(req => req.Content(html), ctx); + + // Extract all URLs from the navigation model + var navigationUrls = GetAllNavigationUrls(navigation).ToHashSet(); + output.WriteLine($"Navigation model URLs: {navigationUrls.Count}"); + + // Extract all URLs from the rendered DOM + var domLinks = document.QuerySelectorAll("a[href]"); + var domUrls = domLinks.Select(link => link.GetAttribute("href")!).ToHashSet(); + output.WriteLine($"DOM URLs: {domUrls.Count}"); + + // Validate that all navigation URLs are present in the DOM + navigationUrls.Should().BeSubsetOf(domUrls, "all navigation URLs should be rendered in the DOM"); + + // Validate that all DOM URLs are from the navigation (no extra links) + domUrls.Should().BeSubsetOf(navigationUrls, "DOM should not contain URLs not in the navigation model"); + + // Validate exact match + navigationUrls.Should().BeEquivalentTo(domUrls, "navigation URLs and DOM URLs should match exactly"); + + // Validate we have a reasonable number of URLs (sanity check) + navigationUrls.Should().NotBeEmpty("navigation should contain URLs"); + navigationUrls.Count.Should().BeGreaterThan(10, "navigation should have a substantial number of items"); + + await collector.StopAsync(TestContext.Current.CancellationToken); + + collector.Errors.Should().Be(0); + + } + + private static void RecurseNav(INodeNavigationItem navigation) + { + foreach (var nav in navigation.NavigationItems) + { + nav.NavigationRoot.Should().NotBeNull(); + if (navigation is not SiteNavigation && nav is not CrossLinkNavigationLeaf) + { + nav.NavigationRoot.Should().NotBeOfType($"{nav.Url}"); + nav.NavigationRoot.Parent.Should().NotBeNull($"{nav.Url}"); + nav.NavigationRoot.Parent.Should().BeOfType($"{nav.Url}"); + } + + if (nav is INodeNavigationItem node) + RecurseNav(node); + } + } + + /// + /// Recursively extracts all URLs from the navigation tree, following the same logic as the Razor templates. + /// Excludes hidden items and parent index items (to match _TocTreeNav.cshtml logic). + /// + private static IEnumerable GetAllNavigationUrls(INavigationItem item) + { + // Skip hidden items (matches _TocTreeNav.cshtml line 9-12) + if (item.Hidden) + yield break; + + // Skip if this item is its parent's index (matches _TocTreeNav.cshtml line 14-16) + if (item.Parent is not null && item.Parent.Index == item) + yield break; + + // Yield the current item's URL + yield return item.Url; + + // Recursively process children if this is a node + if (item is not INodeNavigationItem node) + yield break; + + foreach (var child in node.NavigationItems) + foreach (var url in GetAllNavigationUrls(child)) + yield return url; + } + + + /// + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + if (TestContext.Current.TestState?.Result is TestResult.Passed) + return default; + foreach (var resource in fixture.InMemoryLogger.RecordedLogs) + output.WriteLine(resource.Message); + return default; + } + + /// + public ValueTask InitializeAsync() => default; +} diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/ServeStaticTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/ServeStaticTests.cs new file mode 100644 index 000000000..56e1d8ed0 --- /dev/null +++ b/tests-integration/Elastic.Assembler.IntegrationTests/ServeStaticTests.cs @@ -0,0 +1,35 @@ +// 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 Aspire.Hosting.Testing; +using Elastic.Documentation.Aspire; +using FluentAssertions; + +namespace Elastic.Assembler.IntegrationTests; + +public class ServeStaticTests(DocumentationFixture fixture, ITestOutputHelper output) : IAsyncLifetime +{ + [Fact] + public async Task AssertRequestToRootReturnsData() + { + var client = fixture.DistributedApplication.CreateHttpClient(ResourceNames.AssemblerServe, "http"); + var root = await client.GetStringAsync("/", TestContext.Current.CancellationToken); + _ = root.Should().NotBeNullOrEmpty(); + } + + + /// + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + if (TestContext.Current.TestState?.Result is TestResult.Passed) + return default; + foreach (var resource in fixture.InMemoryLogger.RecordedLogs) + output.WriteLine(resource.Message); + return default; + } + + /// + public ValueTask InitializeAsync() => default; +} diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs new file mode 100644 index 000000000..90e265595 --- /dev/null +++ b/tests-integration/Elastic.Assembler.IntegrationTests/SiteNavigationTests.cs @@ -0,0 +1,232 @@ +// 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; +using Elastic.Documentation; +using Elastic.Documentation.Assembler; +using Elastic.Documentation.Assembler.Sourcing; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Navigation.Assembler; +using Elastic.Markdown.IO; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elastic.Assembler.IntegrationTests; + +public class SiteNavigationTests : IAsyncLifetime +{ + private readonly DocumentationFixture _fixture; + private readonly ITestOutputHelper _output; + private DiagnosticsCollector Collector { get; } + private AssembleContext Context { get; } + private FileSystem FileSystem { get; } + private IDirectoryInfo CheckoutDirectory { get; } + + private bool HasCheckouts() => CheckoutDirectory.Exists; + + public SiteNavigationTests(DocumentationFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + FileSystem = new FileSystem(); + var checkoutDirectory = FileSystem.DirectoryInfo.New( + FileSystem.Path.Combine(Paths.GetSolutionDirectory()!.FullName, ".artifacts", "checkouts") + ); + CheckoutDirectory = checkoutDirectory.Exists + ? checkoutDirectory.GetDirectories().FirstOrDefault(d => d.Name is "next" or "current") ?? checkoutDirectory + : checkoutDirectory; + Collector = new DiagnosticsCollector([]); + var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); + var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); + Context = new AssembleContext(config, configurationContext, "dev", Collector, FileSystem, FileSystem, CheckoutDirectory.FullName, null); + } + + private Checkout CreateCheckout(IFileSystem fs, Repository repository) + { + var name = repository.Name; + var path = repository.Path is { } p + ? fs.DirectoryInfo.New(p) + : fs.DirectoryInfo.New(fs.Path.Combine(Path.Combine(CheckoutDirectory.FullName, name))); + return new Checkout + { + Repository = new Repository + { + Name = name, + Origin = $"elastic/{name}" + }, + HeadReference = Guid.NewGuid().ToString(), + Directory = path + }; + } + + private async Task<(AssembleSources Sources, SiteNavigation Navigation)> Setup() + { + _ = Collector.StartAsync(TestContext.Current.CancellationToken); + + var repos = Context.Configuration.AvailableRepositories + .Where(kv => !kv.Value.Skip) + .Select(kv => kv.Value) + .ToArray(); + var checkouts = repos.Select(r => CreateCheckout(FileSystem, r)).ToArray(); + var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); + var assembleSources = await AssembleSources.AssembleAsync( + NullLoggerFactory.Instance, Context, checkouts, configurationContext, ExportOptions.Default, TestContext.Current.CancellationToken + ); + + var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; + var siteNavigationFile = SiteNavigationFile.Deserialize(await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken)); + var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray(); + var navigation = new SiteNavigation(siteNavigationFile, Context, documentationSets, Context.Environment.PathPrefix); + + return (assembleSources, navigation); + } + + [Fact] + public async Task ReadAllPathPrefixes() + { + Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); + + await using var collector = new DiagnosticsCollector([]); + + var fileSystem = new FileSystem(); + var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); + var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); + var context = new AssembleContext(config, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); + + var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; + var siteNavigationFile = SiteNavigationFile.Deserialize(await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken)); + + var declaredSources = SiteNavigationFile.GetAllDeclaredSources(siteNavigationFile); + declaredSources.Should().NotBeEmpty(); + declaredSources.Should().Contain(new Uri("eland://reference")); + } + + [Fact] + public async Task SiteNavigationNodesContainAllDocumentationSets() + { + Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); + + var (assembleSources, navigation) = await Setup(); + + navigation.Nodes.Should().NotBeEmpty(); + navigation.Nodes.Should().ContainKey(new Uri("detection-rules://")); + } + + + [Fact] + public async Task ParsesReferences() + { + Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); + + var expectedRoot = new Uri("docs-content://"); + var dotnetAgentSource = new Uri("apm-agent-dotnet://"); + var detectionRulesSource = new Uri("detection-rules://"); + var (assembleSources, navigation) = await Setup(); + + // Verify that the navigation nodes contain the expected sources + navigation.Nodes.Should().NotBeEmpty().And.ContainKey(dotnetAgentSource); + navigation.Nodes.Should().ContainKey(expectedRoot); + navigation.Nodes.Should().ContainKey(detectionRulesSource); + + // Verify the navigation structure is built correctly + navigation.NavigationItems.Should().NotBeEmpty(); + + // Find a specific navigation item in the tree (apm-agent-dotnet) + var dotnetAgentNode = navigation.Nodes[dotnetAgentSource]; + dotnetAgentNode.Should().NotBeNull(); + dotnetAgentNode.Identifier.Should().Be(dotnetAgentSource); + + // Verify that the resolved navigation has the expected items + navigation.NavigationItems.Should().NotBeNull(); + } + + + + [Fact] + public async Task ParsesSiteNavigation() + { + Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); + + var kibanaSource = new Uri("kibana://"); + var integrationsRepoName = "integrations"; + var docsContentRepoName = "docs-content"; + + var (assembleSources, navigation) = await Setup(); + + // Verify kibana is in the navigation nodes + navigation.Nodes.Should().NotBeEmpty().And.ContainKey(kibanaSource); + + // Verify integrations and docs-content sources exist in assembleSources + assembleSources.AssembleSets.Should().NotBeEmpty(); + assembleSources.AssembleSets.Should().ContainKey(integrationsRepoName); + assembleSources.AssembleSets.Should().ContainKey(docsContentRepoName); + + // Verify the SiteNavigation is constructed properly + navigation.NavigationItems.Should().NotBeNull().And.NotBeEmpty(); + navigation.TopLevelItems.Should().NotBeEmpty(); + navigation.TopLevelItems.Count.Should().BeLessThan(20); + + // Verify parent-child relationships + var firstTopLevelItem = navigation.NavigationItems.First(); + firstTopLevelItem.Should().NotBeNull(); + firstTopLevelItem.Parent.Should().Be(navigation); + firstTopLevelItem.NavigationRoot.Should().Be(firstTopLevelItem); + } + + [Fact] + public async Task UriResolving() + { + Assert.SkipUnless(HasCheckouts(), $"Requires local checkout folder: {CheckoutDirectory.FullName}"); + + await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); + + var fs = new FileSystem(); + var configurationContext = TestHelpers.CreateConfigurationContext(fs); + var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); + var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, fs, fs, null, null); + var repos = assembleContext.Configuration.AvailableRepositories + .Where(kv => !kv.Value.Skip) + .Select(kv => kv.Value) + .ToArray(); + var checkouts = repos.Select(r => CreateCheckout(fs, r)).ToArray(); + var assembleSources = await AssembleSources.AssembleAsync( + NullLoggerFactory.Instance, assembleContext, checkouts, configurationContext, ExportOptions.Default, TestContext.Current.CancellationToken + ); + + var uriResolver = assembleSources.UriResolver; + + // docs-content://reference/apm/something.md - url hasn't changed + var resolvedUri = uriResolver.Resolve(new Uri("docs-content://reference/apm/something.md"), "reference/apm/something"); + resolvedUri.Should().Be("https://www.elastic.co/docs/reference/apm/something"); + + resolvedUri = uriResolver.Resolve(new Uri("apm-agent-nodejs://reference/instrumentation.md"), "reference/instrumentation"); + resolvedUri.Should().Be("https://www.elastic.co/docs/reference/apm/agents/nodejs/instrumentation"); + + resolvedUri = uriResolver.Resolve(new Uri("apm-agent-dotnet://reference/a/file.md"), "reference/a/file"); + resolvedUri.Should().Be("https://www.elastic.co/docs/reference/apm/agents/dotnet/a/file"); + + resolvedUri = uriResolver.Resolve(new Uri("elasticsearch-net://reference/b/file.md"), "reference/b/file"); + resolvedUri.Should().Be("https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/b/file"); + + resolvedUri = uriResolver.Resolve(new Uri("elasticsearch://extend/c/file.md"), "extend/c/file"); + resolvedUri.Should().Be("https://www.elastic.co/docs/extend/elasticsearch/c/file"); + } + + /// + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + if (TestContext.Current.TestState?.Result is TestResult.Passed) + return default; + foreach (var resource in _fixture.InMemoryLogger.RecordedLogs) + _output.WriteLine(resource.Message); + return default; + } + + /// + public ValueTask InitializeAsync() => default; +} diff --git a/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs b/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs new file mode 100644 index 000000000..b4faaf910 --- /dev/null +++ b/tests-integration/Elastic.Assembler.IntegrationTests/TestLogger.cs @@ -0,0 +1,52 @@ +// 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 Elastic.Documentation.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Elastic.Assembler.IntegrationTests; + +public class TestLogger(ITestOutputHelper? output) : ILogger +{ + private sealed class NullScope : IDisposable + { + public void Dispose() { } + } + + public IDisposable BeginScope(TState state) where TState : notnull => new NullScope(); + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Trace; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + output?.WriteLine(formatter(state, exception)); +} + +public class TestLoggerFactory(ITestOutputHelper? output) : ILoggerFactory +{ + public void Dispose() => GC.SuppressFinalize(this); + + public void AddProvider(ILoggerProvider provider) { } + + public ILogger CreateLogger(string categoryName) => new TestLogger(output); +} +public class TestDiagnosticsOutput(ITestOutputHelper output) : IDiagnosticsOutput +{ + public void Write(Diagnostic diagnostic) + { + if (diagnostic.Severity == Severity.Error) + output.WriteLine($"Error: {diagnostic.Message} ({diagnostic.File}:{diagnostic.Line})"); + else + output.WriteLine($"Warn : {diagnostic.Message} ({diagnostic.File}:{diagnostic.Line})"); + } +} + +public class TestDiagnosticsCollector(ITestOutputHelper output) + : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) +{ + private readonly List _diagnostics = []; + + public IReadOnlyCollection Diagnostics => _diagnostics; + + protected override void HandleItem(Diagnostic diagnostic) => _diagnostics.Add(diagnostic); +} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 2339f7c53..48c968500 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -27,5 +27,4 @@ - diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs new file mode 100644 index 000000000..67c98fbc6 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs @@ -0,0 +1,870 @@ +// 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 System.Runtime.InteropServices; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Extensions; +using FluentAssertions; + +namespace Elastic.Documentation.Configuration.Tests; + +public class DocumentationSetFileTests +{ + // Tests use direct deserialization to test YAML parsing without TOC loading/resolution + private DocumentationSetFile Deserialize(string yaml) => + ConfigurationFileProvider.Deserializer.Deserialize(yaml); + + [Fact] + public void DeserializesBasicProperties() + { + // language=yaml + var yaml = """ + project: 'test-project' + max_toc_depth: 3 + dev_docs: true + cross_links: + - docs-content + - other-docs + exclude: + - '_*.md' + - '*.tmp' + """; + + var result = Deserialize(yaml); + + result.Project.Should().Be("test-project"); + result.MaxTocDepth.Should().Be(3); + result.DevDocs.Should().BeTrue(); + result.CrossLinks.Should().HaveCount(2) + .And.Contain("docs-content") + .And.Contain("other-docs"); + result.Exclude.Should().HaveCount(2) + .And.Contain("_*.md") + .And.Contain("*.tmp"); + } + + [Fact] + public void DeserializesSubstitutions() + { + // language=yaml + var yaml = """ + project: 'test-project' + subs: + stack: Elastic Stack + ecloud: Elastic Cloud + dbuild: docs-builder + """; + + var result = Deserialize(yaml); + + result.Subs.Should().HaveCount(3) + .And.ContainKey("stack").WhoseValue.Should().Be("Elastic Stack"); + result.Subs.Should().ContainKey("ecloud").WhoseValue.Should().Be("Elastic Cloud"); + result.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder"); + } + + [Fact] + public void DeserializesFeatures() + { + // language=yaml + var yaml = """ + project: 'test-project' + features: + primary-nav: false + """; + + var result = Deserialize(yaml); + + result.Features.Should().NotBeNull(); + result.Features.PrimaryNav.Should().BeFalse(); + } + + [Fact] + public void DeserializesApiConfiguration() + { + // language=yaml + var yaml = """ + project: 'test-project' + api: + elasticsearch: elasticsearch-openapi.json + kibana: kibana-openapi.json + """; + + var result = Deserialize(yaml); + + result.Api.Should().HaveCount(2) + .And.ContainKey("elasticsearch").WhoseValue.Should().Be("elasticsearch-openapi.json"); + result.Api.Should().ContainKey("kibana").WhoseValue.Should().Be("kibana-openapi.json"); + } + + [Fact] + public void DeserializesFileReference() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - file: getting-started.md + """; + + var result = Deserialize(yaml); + + result.TableOfContents.Should().HaveCount(2); + result.TableOfContents.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("index.md"); + result.TableOfContents.ElementAt(1).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("getting-started.md"); + } + + [Fact] + public void DeserializesHiddenFileReference() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - hidden: 404.md + - hidden: developer-notes.md + """; + + var result = Deserialize(yaml); + + result.TableOfContents.Should().HaveCount(3); + result.TableOfContents.ElementAt(0).Should().BeOfType() + .Which.Hidden.Should().BeFalse(); + result.TableOfContents.ElementAt(1).Should().BeOfType() + .Which.Hidden.Should().BeTrue(); + result.TableOfContents.ElementAt(1).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("404.md"); + result.TableOfContents.ElementAt(2).Should().BeOfType() + .Which.Hidden.Should().BeTrue(); + } + + [Fact] + public void DeserializesFolderReference() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: contribute + children: + - file: index.md + - file: locally.md + """; + + var result = Deserialize(yaml); + + result.TableOfContents.Should().HaveCount(1); + var folder = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; + folder.PathRelativeToDocumentationSet.Should().Be("contribute"); + folder.Children.Should().HaveCount(2); + folder.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("index.md"); + folder.Children.ElementAt(1).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("locally.md"); + } + + [Fact] + public void DeserializesTocReference() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - toc: development + """; + + var result = Deserialize(yaml); + + result.TableOfContents.Should().HaveCount(2); + result.TableOfContents.ElementAt(0).Should().BeOfType(); + result.TableOfContents.ElementAt(1).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("development"); + } + + [Fact] + public void DeserializesCrossLinkReference() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - file: cross-links.md + children: + - title: "Getting Started Guide" + crosslink: docs-content://get-started/introduction.md + """; + + var result = Deserialize(yaml); + + result.TableOfContents.Should().HaveCount(2); + var fileWithChildren = result.TableOfContents.ElementAt(1).Should().BeOfType().Subject; + fileWithChildren.Children.Should().HaveCount(1); + var crosslink = fileWithChildren.Children.ElementAt(0).Should().BeOfType().Subject; + crosslink.Title.Should().Be("Getting Started Guide"); + crosslink.CrossLinkUri.ToString().Should().Be("docs-content://get-started/introduction.md"); + } + + [Fact] + public void DeserializesNestedStructure() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: configure + children: + - file: index.md + - folder: site + children: + - file: index.md + - file: content.md + - file: navigation.md + """; + + var result = Deserialize(yaml); + + result.TableOfContents.Should().HaveCount(1); + var topFolder = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; + topFolder.PathRelativeToDocumentationSet.Should().Be("configure"); + topFolder.Children.Should().HaveCount(2); + + topFolder.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("index.md"); + + var nestedFolder = topFolder.Children.ElementAt(1).Should().BeOfType().Subject; + nestedFolder.PathRelativeToDocumentationSet.Should().Be("site"); + nestedFolder.Children.Should().HaveCount(3); + nestedFolder.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("index.md"); + nestedFolder.Children.ElementAt(1).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("content.md"); + nestedFolder.Children.ElementAt(2).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("navigation.md"); + } + + [Fact] + public void DeserializesCompleteDocsetYaml() + { + // language=yaml + var yaml = """ + project: 'doc-builder' + max_toc_depth: 2 + dev_docs: true + cross_links: + - docs-content + exclude: + - '_*.md' + subs: + stack: Elastic Stack + serverless-short: Serverless + ecloud: Elastic Cloud + features: + primary-nav: false + api: + elasticsearch: elasticsearch-openapi.json + kibana: kibana-openapi.json + toc: + - file: index.md + - hidden: 404.md + - folder: configure + children: + - file: index.md + - folder: site + children: + - file: index.md + - file: content.md + - file: navigation.md + - file: page.md + children: + - title: "Getting Started Guide" + crosslink: docs-content://get-started/introduction.md + - toc: development + """; + + var result = Deserialize(yaml); + + // Assert top-level docset properties + result.Project.Should().Be("doc-builder"); + result.MaxTocDepth.Should().Be(2); + result.DevDocs.Should().BeTrue(); + result.CrossLinks.Should().ContainSingle().Which.Should().Be("docs-content"); + result.Exclude.Should().ContainSingle().Which.Should().Be("_*.md"); + result.Subs.Should().HaveCount(3); + result.Features.PrimaryNav.Should().BeFalse(); + result.Api.Should().HaveCount(2); + + // Assert TOC structure - 4 root items + result.TableOfContents.Should().HaveCount(4); + + // First item: simple file reference + var firstItem = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; + firstItem.PathRelativeToDocumentationSet.Should().Be("index.md"); + firstItem.Hidden.Should().BeFalse(); + firstItem.Children.Should().BeEmpty(); + + // Second item: hidden file reference + var secondItem = result.TableOfContents.ElementAt(1).Should().BeOfType().Subject; + secondItem.PathRelativeToDocumentationSet.Should().Be("404.md"); + secondItem.Hidden.Should().BeTrue(); + secondItem.Children.Should().BeEmpty(); + + // Third item: folder with a deeply nested structure + var configureFolder = result.TableOfContents.ElementAt(2).Should().BeOfType().Subject; + configureFolder.PathRelativeToDocumentationSet.Should().Be("configure"); + configureFolder.Children.Should().HaveCount(3); + + // First child: file reference + var configureIndexFile = configureFolder.Children.ElementAt(0).Should().BeOfType().Subject; + configureIndexFile.PathRelativeToDocumentationSet.Should().Be("index.md"); + configureIndexFile.Hidden.Should().BeFalse(); + + // Second child: nested folder with 3 files + var siteFolder = configureFolder.Children.ElementAt(1).Should().BeOfType().Subject; + siteFolder.PathRelativeToDocumentationSet.Should().Be("site"); + siteFolder.Children.Should().HaveCount(3); + + // Assert nested folder's children + var siteIndexFile = siteFolder.Children.ElementAt(0).Should().BeOfType().Subject; + siteIndexFile.PathRelativeToDocumentationSet.Should().Be("index.md"); + + var contentFile = siteFolder.Children.ElementAt(1).Should().BeOfType().Subject; + contentFile.PathRelativeToDocumentationSet.Should().Be("content.md"); + + var navigationFile = siteFolder.Children.ElementAt(2).Should().BeOfType().Subject; + navigationFile.PathRelativeToDocumentationSet.Should().Be("navigation.md"); + + // Third child: file with crosslink child + var pageFile = configureFolder.Children.ElementAt(2).Should().BeOfType().Subject; + pageFile.PathRelativeToDocumentationSet.Should().Be("page.md"); + pageFile.Children.Should().HaveCount(1); + + // Assert crosslink reference as a child of page.md + var crosslink = pageFile.Children.ElementAt(0).Should().BeOfType().Subject; + crosslink.Title.Should().Be("Getting Started Guide"); + crosslink.CrossLinkUri.ToString().Should().Be("docs-content://get-started/introduction.md"); + crosslink.Hidden.Should().BeFalse(); + crosslink.Children.Should().BeEmpty(); + + // Fourth item: toc reference + var tocRef = result.TableOfContents.ElementAt(3).Should().BeOfType().Subject; + tocRef.PathRelativeToDocumentationSet.Should().Be("development"); + tocRef.Children.Should().BeEmpty(); + } + + [Fact] + public void DeserializesFileWithChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: chapter1.md + - file: chapter2.md + - file: chapter3.md + """; + + var result = Deserialize(yaml); + + result.TableOfContents.Should().HaveCount(1); + var guide = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; + guide.PathRelativeToDocumentationSet.Should().Be("guide.md"); + guide.Children.Should().HaveCount(3); + guide.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("chapter1.md"); + guide.Children.ElementAt(1).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("chapter2.md"); + guide.Children.ElementAt(2).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("chapter3.md"); + } + + [Fact] + public void DeserializesFileWithNestedPathsAsChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: api/guide.md + children: + - file: api/section1.md + - file: api/section2.md + """; + + var result = Deserialize(yaml); + + result.TableOfContents.Should().HaveCount(1); + var guide = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; + guide.PathRelativeToDocumentationSet.Should().Be("api/guide.md"); + guide.Children.Should().HaveCount(2); + guide.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("api/section1.md"); + guide.Children.ElementAt(1).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("api/section2.md"); + } + + [Fact] + public void DeserializesDefaultValues() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + """; + + var result = Deserialize(yaml); + + result.MaxTocDepth.Should().Be(2); // Default value + result.DevDocs.Should().BeFalse(); // Default value + result.CrossLinks.Should().BeEmpty(); + result.Exclude.Should().BeEmpty(); + result.Subs.Should().BeEmpty(); + result.Api.Should().BeEmpty(); + result.Features.PrimaryNav.Should().BeNull(); + } + + [Fact] + public void DeserializesEmptyToc() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: [] + """; + + var result = Deserialize(yaml); + + result.TableOfContents.Should().BeEmpty(); + } + + [Fact] + public void DeserializesCrossLinkWithoutTitle() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + children: + - crosslink: docs-content://get-started.md + """; + + var result = Deserialize(yaml); + + var file = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; + var crosslink = file.Children.ElementAt(0).Should().BeOfType().Subject; + crosslink.CrossLinkUri.ToString().Should().Be("docs-content://get-started.md/"); // URI normalization adds trailing slash + crosslink.Title.Should().BeNull(); + } + + [Fact] + public void DeserializesMixedHiddenAndVisibleItems() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - hidden: _internal.md + - file: public.md + - hidden: _draft.md + """; + + var result = Deserialize(yaml); + + result.TableOfContents.Should().HaveCount(4); + result.TableOfContents.ElementAt(0).Should().BeOfType() + .Which.Hidden.Should().BeFalse(); + result.TableOfContents.ElementAt(1).Should().BeOfType() + .Which.Hidden.Should().BeTrue(); + result.TableOfContents.ElementAt(2).Should().BeOfType() + .Which.Hidden.Should().BeFalse(); + result.TableOfContents.ElementAt(3).Should().BeOfType() + .Which.Hidden.Should().BeTrue(); + } + + [Fact] + public void DeserializesDeeplyNestedFileWithChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: chapter1.md + children: + - file: section1.md + children: + - file: subsection1.md + """; + + var result = Deserialize(yaml); + + var guide = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; + var chapter1 = guide.Children.ElementAt(0).Should().BeOfType().Subject; + var section1 = chapter1.Children.ElementAt(0).Should().BeOfType().Subject; + var subsection1 = section1.Children.ElementAt(0).Should().BeOfType().Subject; + + subsection1.PathRelativeToDocumentationSet.Should().Be("subsection1.md"); + subsection1.Children.Should().BeEmpty(); + } + + [Fact] + public void DeserializesMultipleExcludePatterns() + { + // language=yaml + var yaml = """ + project: 'test-project' + exclude: + - '_*.md' + - '*.tmp' + - '*.draft' + - '.DS_Store' + - 'node_modules/**' + toc: + - file: index.md + """; + + var result = Deserialize(yaml); + + result.Exclude.Should().HaveCount(5) + .And.ContainInOrder("_*.md", "*.tmp", "*.draft", ".DS_Store", "node_modules/**"); + } + + [Fact] + public void DeserializesMultipleCrossLinks() + { + // language=yaml + var yaml = """ + project: 'test-project' + cross_links: + - elasticsearch + - kibana + - docs-content + - cloud + toc: + - file: index.md + """; + + var result = Deserialize(yaml); + + result.CrossLinks.Should().HaveCount(4) + .And.ContainInOrder("elasticsearch", "kibana", "docs-content", "cloud"); + } + + [Fact] + public void DeserializesFolderWithMixedChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: api + children: + - file: index.md + - folder: rest + children: + - file: index.md + - file: overview.md + """; + + var result = Deserialize(yaml); + + var apiFolder = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; + apiFolder.Children.Should().HaveCount(3); + apiFolder.Children.ElementAt(0).Should().BeOfType(); + apiFolder.Children.ElementAt(1).Should().BeOfType(); + apiFolder.Children.ElementAt(2).Should().BeOfType(); + } + + [Fact] + public void LoadAndResolveResolvesIsolatedTocReferences() + { + // Create a mock file system with docset and nested TOC files + var fileSystem = new MockFileSystem(); + + // Main docset.yml + // language=yaml + var docsetYaml = """ + project: 'test-project' + toc: + - file: index.md + - toc: development + - folder: guides + children: + - file: getting-started.md + - toc: advanced + """; + + // development/toc.yml + // language=yaml + var developmentTocYaml = """ + toc: + - file: index.md + - file: contributing.md + - folder: internals + children: + - file: architecture.md + """; + + // guides/advanced/toc.yml + // language=yaml + var advancedTocYaml = """ + toc: + - file: index.md + - file: patterns.md + """; + + fileSystem.AddFile("/docs/docset.yml", new MockFileData(docsetYaml)); + fileSystem.AddFile("/docs/development/toc.yml", new MockFileData(developmentTocYaml)); + fileSystem.AddFile("/docs/guides/advanced/toc.yml", new MockFileData(advancedTocYaml)); + + var docsetPath = fileSystem.FileInfo.New("/docs/docset.yml"); + var collector = new DiagnosticsCollector([]); + var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, fileSystem); + + // Verify TOC references have been preserved (not flattened) + // We have 3 top-level items: index.md, development TOC, and guides folder + result.TableOfContents.Should().HaveCount(3); + + // First item: file from main docset + result.TableOfContents.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("index.md"); + + // Second item: development TOC (preserved as IsolatedTableOfContentsRef with resolved children) + var developmentToc = result.TableOfContents.ElementAt(1).Should().BeOfType().Subject; + developmentToc.PathRelativeToDocumentationSet.Should().Be("development"); + developmentToc.Children.Should().HaveCount(3, "should have index, contributing file, and internals folder"); + + developmentToc.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("development/index.md", "TOC path should be prepended"); + + developmentToc.Children.ElementAt(1).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("development/contributing.md"); + + var internalsFolder = developmentToc.Children.ElementAt(2).Should().BeOfType().Subject; + internalsFolder.PathRelativeToDocumentationSet.Should().Be("development/internals"); + internalsFolder.Children.Should().HaveCount(1); + internalsFolder.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("development/internals/architecture.md"); + + // Third item: guides folder (preserved with its children including nested advanced TOC) + var guidesFolder = result.TableOfContents.ElementAt(2).Should().BeOfType().Subject; + guidesFolder.PathRelativeToDocumentationSet.Should().Be("guides"); + guidesFolder.Children.Should().HaveCount(2, "should have getting-started file and advanced TOC"); + + guidesFolder.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("guides/getting-started.md"); + + // Advanced TOC preserved as IsolatedTableOfContentsRef within guides folder + var advancedToc = guidesFolder.Children.ElementAt(1).Should().BeOfType().Subject; + advancedToc.PathRelativeToDocumentationSet.Should().Be("guides/advanced"); + advancedToc.Children.Should().HaveCount(2); + + advancedToc.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("guides/advanced/index.md"); + + advancedToc.Children.ElementAt(1).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("guides/advanced/patterns.md"); + } + + [Fact] + public void LoadAndResolvePrependsParentPathsToFileReferences() + { + var fileSystem = new MockFileSystem(); + + // language=yaml + var docsetYaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: chapter1.md + children: + - file: section1.md + - folder: api + children: + - file: index.md + - file: reference.md + """; + + fileSystem.AddFile("/docs/docset.yml", new MockFileData(docsetYaml)); + + var docsetPath = fileSystem.FileInfo.New("/docs/docset.yml"); + var collector = new DiagnosticsCollector([]); + var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, fileSystem); + + result.TableOfContents.Should().HaveCount(2); + + // First item: file with nested children + var guide = result.TableOfContents.ElementAt(0).Should().BeOfType().Subject; + guide.PathRelativeToDocumentationSet.Should().Be("guide.md"); + guide.Children.Should().HaveCount(1); + + var chapter1 = guide.Children.ElementAt(0).Should().BeOfType().Subject; + chapter1.PathRelativeToDocumentationSet.Should().Be("chapter1.md", "children of files stay in the same directory"); + chapter1.Children.Should().HaveCount(1); + + var section1 = chapter1.Children.ElementAt(0).Should().BeOfType().Subject; + section1.PathRelativeToDocumentationSet.Should().Be("section1.md", "children of files stay in the same directory"); + + // Second item: folder with children + var apiFolder = result.TableOfContents.ElementAt(1).Should().BeOfType().Subject; + apiFolder.PathRelativeToDocumentationSet.Should().Be("api"); + apiFolder.Children.Should().HaveCount(2); + + apiFolder.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("api/index.md", "folder path 'api' should be prepended"); + + apiFolder.Children.ElementAt(1).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("api/reference.md", "folder path 'api' should be prepended"); + } + + [Fact] + public void LoadAndResolveSetsContextForAllItems() + { + var fileSystem = new MockFileSystem(); + + // language=yaml + var docsetYaml = """ + project: 'test-project' + toc: + - file: index.md + - folder: guides + children: + - file: getting-started.md + - toc: development + """; + + // development/toc.yml + // language=yaml + var developmentTocYaml = """ + toc: + - file: contributing.md + """; + + fileSystem.AddFile("/docs/docset.yml", new MockFileData(docsetYaml)); + fileSystem.AddFile("/docs/development/toc.yml", new MockFileData(developmentTocYaml)); + + var docsetPath = fileSystem.FileInfo.New("/docs/docset.yml"); + var collector = new DiagnosticsCollector([]); + var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, fileSystem); + + var docset = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:/docs/docset.yml" : "/docs/docset.yml"; + var toc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:/docs/development/toc.yml" : "/docs/development/toc.yml"; + + // All items from docset.yml should have context = /docs/docset.yml + result.TableOfContents.ElementAt(0).Should().BeOfType() + .Which.Context.OptionalWindowsReplace().Should().Be(docset); + + var guidesFolder = result.TableOfContents.ElementAt(1).Should().BeOfType().Subject; + guidesFolder.Context.Should().Be(docset); + guidesFolder.Children.ElementAt(0).Should().BeOfType() + .Which.Context.OptionalWindowsReplace().Should().Be(docset); + + // The TOC ref itself has context = /docs/docset.yml (where it was referenced) + var developmentToc = result.TableOfContents.ElementAt(2).Should().BeOfType().Subject; + developmentToc.Context.OptionalWindowsReplace().Should().Be(docset); + + // But children of the TOC ref should have context = /docs/development/toc.yml (where they were defined) + developmentToc.Children.ElementAt(0).Should().BeOfType() + .Which.Context.OptionalWindowsReplace().Should().Be(toc); + } + + [Fact] + public void LoadAndResolveSetsPathRelativeToContainerCorrectly() + { + var fileSystem = new MockFileSystem(); + + // Main docset.yml + // language=yaml + var docsetYaml = """ + project: 'test-project' + toc: + - file: index.md + - folder: guides + children: + - file: getting-started.md + - toc: development + """; + + // development/toc.yml + // language=yaml + var developmentTocYaml = """ + toc: + - file: overview.md + - folder: advanced + children: + - file: patterns.md + - toc: internals + """; + + // development/internals/toc.yml + // language=yaml + var internalsTocYaml = """ + toc: + - file: architecture.md + """; + + fileSystem.AddFile("/docs/docset.yml", new MockFileData(docsetYaml)); + fileSystem.AddFile("/docs/development/toc.yml", new MockFileData(developmentTocYaml)); + fileSystem.AddFile("/docs/development/internals/toc.yml", new MockFileData(internalsTocYaml)); + + var docsetPath = fileSystem.FileInfo.New("/docs/docset.yml"); + var collector = new DiagnosticsCollector([]); + var result = DocumentationSetFile.LoadAndResolve(collector, docsetPath, fileSystem); + + // Items in docset.yml: PathRelativeToContainer should equal PathRelativeToDocumentationSet + result.TableOfContents.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToContainer.Should().Be("index.md", "file in root docset.yml"); + + var guidesFolder = result.TableOfContents.ElementAt(1).Should().BeOfType().Subject; + guidesFolder.PathRelativeToContainer.Should().Be("guides", "folder in root docset.yml"); + guidesFolder.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToContainer.Should().Be("guides/getting-started.md", "file's full path from container (docset.yml)"); + + // Development TOC in docset.yml + var developmentToc = result.TableOfContents.ElementAt(2).Should().BeOfType().Subject; + developmentToc.PathRelativeToContainer.Should().Be("development", "toc ref in root docset.yml"); + + // Items in development/toc.yml: PathRelativeToContainer should be relative to development/ + developmentToc.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToContainer.Should().Be("overview.md", "file in development/toc.yml"); + + var advancedFolder = developmentToc.Children.ElementAt(1).Should().BeOfType().Subject; + advancedFolder.PathRelativeToContainer.Should().Be("advanced", "folder in development/toc.yml"); + advancedFolder.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToContainer.Should().Be("advanced/patterns.md", "file's full path from container (development/toc.yml)"); + + // Internals TOC in development/toc.yml + var internalsToc = developmentToc.Children.ElementAt(2).Should().BeOfType().Subject; + internalsToc.PathRelativeToContainer.Should().Be("internals", "toc ref in development/toc.yml"); + + // Items in development/internals/toc.yml: PathRelativeToContainer should be relative to development/internals/ + internalsToc.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToContainer.Should().Be("architecture.md", "file in development/internals/toc.yml"); + + // Verify PathRelativeToDocumentationSet is still correct (full paths from docset root) + guidesFolder.PathRelativeToDocumentationSet.Should().Be("guides"); + guidesFolder.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("guides/getting-started.md"); + + developmentToc.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("development/overview.md"); + + internalsToc.Children.ElementAt(0).Should().BeOfType() + .Which.PathRelativeToDocumentationSet.Should().Be("development/internals/architecture.md"); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs new file mode 100644 index 000000000..2ef1ad402 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -0,0 +1,138 @@ +// 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 Elastic.Documentation.Configuration.Toc; +using FluentAssertions; + +namespace Elastic.Documentation.Configuration.Tests; + +public class PhysicalDocsetTests +{ + [Fact] + public void PhysicalDocsetFileCanBeDeserialized() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + File.Exists(docsetPath).Should().BeTrue($"Expected docset file to exist at {docsetPath}"); + + var yaml = File.ReadAllText(docsetPath); + // Tests use direct deserialization to test YAML parsing without TOC loading/resolution + var docSet = ConfigurationFileProvider.Deserializer.Deserialize(yaml); + + // Assert basic properties + docSet.Project.Should().Be("doc-builder"); + docSet.MaxTocDepth.Should().Be(2); + docSet.DevDocs.Should().BeTrue(); + docSet.Features.PrimaryNav.Should().BeFalse(); + + // Assert cross links + docSet.CrossLinks.Should().ContainSingle().Which.Should().Be("docs-content"); + + // Assert exclude patterns + docSet.Exclude.Should().ContainSingle().Which.Should().Be("_*.md"); + + // Assert substitutions + docSet.Subs.Should().NotBeEmpty(); + docSet.Subs.Should().ContainKey("stack").WhoseValue.Should().Be("Elastic Stack"); + docSet.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder"); + + // Assert API configuration + docSet.Api.Should().HaveCount(2); + docSet.Api.Should().ContainKey("elasticsearch").WhoseValue.Should().Be("elasticsearch-openapi.json"); + docSet.Api.Should().ContainKey("kibana").WhoseValue.Should().Be("kibana-openapi.json"); + + // Assert TOC structure + docSet.TableOfContents.Should().NotBeEmpty(); + + // First item should be index.md + var firstItem = docSet.TableOfContents.ElementAt(0).Should().BeOfType().Subject; + firstItem.PathRelativeToDocumentationSet.Should().Be("index.md"); + firstItem.Hidden.Should().BeFalse(); + + // Should have hidden files (404.md, developer-notes.md) + var hiddenFiles = docSet.TableOfContents.OfType().Where(f => f.Hidden).ToList(); + hiddenFiles.Should().Contain(f => f.PathRelativeToDocumentationSet == "404.md"); + hiddenFiles.Should().Contain(f => f.PathRelativeToDocumentationSet == "developer-notes.md"); + + // Should have folders + docSet.TableOfContents.OfType().Should().NotBeEmpty(); + var contributeFolder = docSet.TableOfContents.OfType().FirstOrDefault(f => f.PathRelativeToDocumentationSet == "contribute"); + contributeFolder.Should().NotBeNull(); + contributeFolder.Children.Should().NotBeEmpty(); + + // Should have TOC references + var tocRefs = docSet.TableOfContents.OfType().ToList(); + tocRefs.Should().NotBeEmpty(); + tocRefs.Should().Contain(toc => toc.PathRelativeToDocumentationSet == "development"); + + // Should have deeply nested structures + var testingFolder = docSet.TableOfContents.OfType().FirstOrDefault(f => f.PathRelativeToDocumentationSet == "testing"); + testingFolder.Should().NotBeNull(); + testingFolder.Children.Should().NotBeEmpty(); + } + + [Fact] + public void PhysicalDocsetContainsExpectedFolders() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var yaml = File.ReadAllText(docsetPath); + // Tests use direct deserialization to test YAML parsing without TOC loading/resolution + var docSet = ConfigurationFileProvider.Deserializer.Deserialize(yaml); + + var folderNames = docSet.TableOfContents.OfType().Select(f => f.PathRelativeToDocumentationSet).ToList(); + + // Assert expected folders exist + folderNames.Should().Contain("contribute"); + folderNames.Should().Contain("building-blocks"); + folderNames.Should().Contain("configure"); + folderNames.Should().Contain("syntax"); + folderNames.Should().Contain("cli"); + folderNames.Should().Contain("migration"); + folderNames.Should().Contain("testing"); + } + + [Fact] + public void PhysicalDocsetHasValidNestedStructure() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var yaml = File.ReadAllText(docsetPath); + // Tests use direct deserialization to test YAML parsing without TOC loading/resolution + var docSet = ConfigurationFileProvider.Deserializer.Deserialize(yaml); + + // Test the configure folder has nested folders + var configureFolder = docSet.TableOfContents.OfType().First(f => f.PathRelativeToDocumentationSet == "configure"); + configureFolder.Children.Should().NotBeEmpty(); + + // Should have site and content-set folders + var nestedFolders = configureFolder.Children.OfType().Select(f => f.PathRelativeToDocumentationSet).ToList(); + nestedFolders.Should().Contain("site"); + nestedFolders.Should().Contain("content-set"); + + // Test the cli folder has nested folders + var cliFolder = docSet.TableOfContents.OfType().First(f => f.PathRelativeToDocumentationSet == "cli"); + var cliNestedFolders = cliFolder.Children.OfType().Select(f => f.PathRelativeToDocumentationSet).ToList(); + cliNestedFolders.Should().Contain("docset"); + cliNestedFolders.Should().Contain("assembler"); + cliNestedFolders.Should().Contain("links"); + } + + [Fact] + public void PhysicalDocsetContainsFileReferencesWithChildren() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var yaml = File.ReadAllText(docsetPath); + // Tests use direct deserialization to test YAML parsing without TOC loading/resolution + var docSet = ConfigurationFileProvider.Deserializer.Deserialize(yaml); + + // Find testing folder + var testingFolder = docSet.TableOfContents.OfType().First(f => f.PathRelativeToDocumentationSet == "testing"); + + // Look for file with children (cross-links.md with crosslink children) + var fileWithChildren = testingFolder.Children.OfType() + .FirstOrDefault(f => f.PathRelativeToDocumentationSet == "cross-links.md" && f.Children.Count > 0); + + fileWithChildren.Should().NotBeNull(); + fileWithChildren.Children.Should().NotBeEmpty(); + fileWithChildren.Children.Should().Contain(c => c is CrossLinkRef); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs new file mode 100644 index 000000000..9d2b0f563 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/SiteNavigationFileTests.cs @@ -0,0 +1,145 @@ +// 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 Elastic.Documentation.Configuration.Toc; +using FluentAssertions; + +namespace Elastic.Documentation.Configuration.Tests; + +public class SiteNavigationFileTests +{ + [Fact] + public void DeserializesSiteNavigationFile() + { + // language=yaml + var yaml = """ + phantoms: + - toc: elasticsearch://reference + - toc: docs-content:// + toc: + - toc: serverless/observability + path_prefix: /serverless/observability + - toc: serverless/search + path_prefix: /serverless/search + - toc: serverless/security + path_prefix: /serverless/security + """; + + var siteNav = SiteNavigationFile.Deserialize(yaml); + + siteNav.Should().NotBeNull(); + siteNav.Phantoms.Should().HaveCount(2); + siteNav.Phantoms.ElementAt(0).Source.Should().Be("elasticsearch://reference"); + siteNav.Phantoms.ElementAt(1).Source.Should().Be("docs-content://"); + + siteNav.TableOfContents.Should().HaveCount(3); + + var observability = siteNav.TableOfContents.ElementAt(0); + observability.Source.ToString().Should().Be("docs-content://serverless/observability"); + observability.PathPrefix.Should().Be("/serverless/observability"); + observability.Children.Should().BeEmpty(); + + var search = siteNav.TableOfContents.ElementAt(1); + search.Source.ToString().Should().Be("docs-content://serverless/search"); + search.PathPrefix.Should().Be("/serverless/search"); + + var security = siteNav.TableOfContents.ElementAt(2); + security.Source.ToString().Should().Be("docs-content://serverless/security"); + security.PathPrefix.Should().Be("/serverless/security"); + } + + [Fact] + public void DeserializesSiteNavigationFileWithNestedChildren() + { + // language=yaml + var yaml = """ + toc: + - toc: platform + path_prefix: /platform + children: + - toc: platform/deployment-guide + path_prefix: /platform/deployment + - toc: platform/cloud-guide + path_prefix: /platform/cloud + """; + + var siteNav = SiteNavigationFile.Deserialize(yaml); + + siteNav.TableOfContents.Should().HaveCount(1); + + var platform = siteNav.TableOfContents.First(); + platform.Source.ToString().Should().Be("docs-content://platform/"); + platform.PathPrefix.Should().Be("/platform"); + platform.Children.Should().HaveCount(2); + + var deployment = platform.Children.ElementAt(0); + deployment.Source.ToString().Should().Be("docs-content://platform/deployment-guide"); + deployment.PathPrefix.Should().Be("/platform/deployment"); + + var cloud = platform.Children.ElementAt(1); + cloud.Source.ToString().Should().Be("docs-content://platform/cloud-guide"); + cloud.PathPrefix.Should().Be("/platform/cloud"); + } + + [Fact] + public void DeserializesWithMissingPath() + { + // language=yaml + var yaml = """ + toc: + - toc: elasticsearch/reference + """; + + var siteNav = SiteNavigationFile.Deserialize(yaml); + + siteNav.TableOfContents.Should().HaveCount(1); + var ref1 = siteNav.TableOfContents.First(); + ref1.Source.ToString().Should().Be("docs-content://elasticsearch/reference"); + ref1.PathPrefix.Should().BeEmpty(); + } + + [Fact] + public void PreservesSchemeWhenPresent() + { + // language=yaml + var yaml = """ + toc: + - toc: elasticsearch://reference/current + - toc: kibana://reference/8.0 + - toc: serverless/observability + """; + + var siteNav = SiteNavigationFile.Deserialize(yaml); + + siteNav.TableOfContents.Should().HaveCount(3); + + // With elasticsearch:// scheme + var elasticsearch = siteNav.TableOfContents.ElementAt(0); + elasticsearch.Source.ToString().Should().Be("elasticsearch://reference/current"); + + // With kibana:// scheme + var kibana = siteNav.TableOfContents.ElementAt(1); + kibana.Source.ToString().Should().Be("kibana://reference/8.0"); + + // Without scheme - should get docs-content:// + var serverless = siteNav.TableOfContents.ElementAt(2); + serverless.Source.ToString().Should().Be("docs-content://serverless/observability"); + } + + [Fact] + public void ThrowsExceptionForInvalidUri() + { + // language=yaml + var yaml = """ + toc: + - toc: ://invalid + """; + + var act = () => SiteNavigationFile.Deserialize(yaml); + + act.Should().Throw() + .WithInnerException() + .WithMessage("Invalid TOC source: '://invalid' could not be parsed as a URI"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index 788afe3d1..447364f1e 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -72,7 +72,7 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") var context = new BuildContext(Collector, FileSystem, configurationContext); var linkResolver = new TestCrossLinkResolver(); Set = new DocumentationSet(context, logger, linkResolver); - File = Set.DocumentationFileLookup(FileSystem.FileInfo.New("docs/index.md")) as MarkdownFile ?? throw new NullReferenceException(); + File = Set.TryFindDocument(FileSystem.FileInfo.New("docs/index.md")) as MarkdownFile ?? throw new NullReferenceException(); Html = default!; //assigned later Document = default!; } @@ -83,7 +83,7 @@ public virtual async ValueTask InitializeAsync() { _ = Collector.StartAsync(TestContext.Current.CancellationToken); - Document = await File.ParseFullAsync(TestContext.Current.CancellationToken); + Document = await File.ParseFullAsync(Set.TryFindDocumentByRelativePath, TestContext.Current.CancellationToken); var html = MarkdownFile.CreateHtml(Document).AsSpan(); var find = ""; var start = html.IndexOf(find, StringComparison.Ordinal); diff --git a/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs b/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs index cae721667..5c1198c14 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/BreadCrumbTests.cs @@ -2,6 +2,7 @@ // 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 Elastic.Documentation.Extensions; using Elastic.Markdown.IO; using FluentAssertions; @@ -12,23 +13,27 @@ public class BreadCrumbTests(ITestOutputHelper output) : NavigationTestsBase(out [Fact] public void ParsesATableOfContents() { - var doc = Generator.DocumentationSet.Files.FirstOrDefault(f => f.RelativePath == Path.Combine("testing", "nested", "index.md")) as MarkdownFile; - - doc.Should().NotBeNull(); - IPositionalNavigation positionalNavigation = Generator.DocumentationSet; - - var allKeys = positionalNavigation.MarkdownNavigationLookup.Keys.ToList(); + var allKeys = positionalNavigation.NavigationIndexedByCrossLink.Keys; allKeys.Should().Contain("docs-builder://testing/nested/index.md"); + allKeys.Should().Contain("docs-builder://testing/nest-under-index/index.md"); + + var lookup = Path.Combine("testing", "nested", "index.md"); + var doc = Generator.DocumentationSet.MarkdownFiles + .FirstOrDefault(f => f.SourceFile.FullName.EndsWith(lookup, StringComparison.OrdinalIgnoreCase)); + + doc.Should().NotBeNull(); - var f = positionalNavigation.MarkdownNavigationLookup.FirstOrDefault(kv => kv.Key == "docs-builder://testing/deeply-nested/foo.md"); + var f = positionalNavigation.NavigationIndexedByCrossLink.FirstOrDefault(kv => kv.Key == "docs-builder://testing/deeply-nested/foo.md"); f.Should().NotBeNull(); - positionalNavigation.MarkdownNavigationLookup.Should().ContainKey(doc.CrossLink); - var nav = positionalNavigation.MarkdownNavigationLookup[doc.CrossLink]; + positionalNavigation.NavigationIndexedByCrossLink.Should().ContainKey(doc.CrossLink); + var nav = positionalNavigation.NavigationIndexedByCrossLink[doc.CrossLink]; nav.Parent.Should().NotBeNull(); + _ = positionalNavigation.MarkdownNavigationLookup.TryGetValue(doc, out var docNavigation); + docNavigation.Should().NotBeNull(); var parents = positionalNavigation.GetParentsOfMarkdownFile(doc); parents.Should().HaveCount(2); diff --git a/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs b/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs index 64e52f786..e57a81dc4 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NavigationTests.cs @@ -10,21 +10,7 @@ public class NavigationTests(ITestOutputHelper output) : NavigationTestsBase(out { [Fact] public void ParsesATableOfContents() => - Configuration.TableOfContents.Should().NotBeNullOrEmpty(); - - [Fact] - public void ParsesNestedFoldersAndPrefixesPaths() - { - Configuration.ImplicitFolders.Should().NotBeNullOrEmpty(); - Configuration.ImplicitFolders.Should() - .Contain(Path.Combine("testing", "nested")); - } - - [Fact] - public void ParsesFilesAndPrefixesPaths() => - Configuration.Files.Should() - .Contain("index.md") - .And.Contain(Path.Combine("syntax", "index.md")); + Set.Navigation.Should().NotBeNull(); [Fact] public void ParsesRedirects() diff --git a/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs b/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs index ecf937845..8d68a6a67 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NestedTocTests.cs @@ -2,8 +2,12 @@ // 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 Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Leaf; +using Elastic.Documentation.Navigation.Isolated.Node; using Elastic.Markdown.IO; -using Elastic.Markdown.IO.Navigation; using FluentAssertions; namespace Elastic.Markdown.Tests.DocSet; @@ -13,25 +17,27 @@ public class NestedTocTests(ITestOutputHelper output) : NavigationTestsBase(outp [Fact] public void InjectsNestedTocsIntoDocumentationSet() { - var doc = Generator.DocumentationSet.Files.FirstOrDefault(f => f.RelativePath == Path.Combine("development", "index.md")) as MarkdownFile; + var doc = Generator.DocumentationSet.MarkdownFiles.FirstOrDefault(f => f.RelativePath == Path.Combine("development", "index.md")); doc.Should().NotBeNull(); IPositionalNavigation positionalNavigation = Generator.DocumentationSet; - positionalNavigation.MarkdownNavigationLookup.Should().ContainKey(doc.CrossLink); - var nav = positionalNavigation.MarkdownNavigationLookup[doc.CrossLink]; + positionalNavigation.MarkdownNavigationLookup.Should().ContainKey(doc); + if (!positionalNavigation.MarkdownNavigationLookup.TryGetValue(doc, out var nav)) + throw new Exception($"Could not find nav item for {doc.CrossLink}"); + nav.Should().BeOfType>(); var parent = nav.Parent; // ensure we link back up to the main toc in docset yaml parent.Should().NotBeNull(); - - // its parent should be null - parent.Parent.Should().BeNull(); + parent.Should().BeOfType>(); // its parent should point to an index - var index = (parent as DocumentationGroup)?.Index; + var index = (parent as DocumentationSetNavigation)?.Index; index.Should().NotBeNull(); - index.RelativePath.Should().Be("index.md"); + var fileNav = index as FileNavigationLeaf; + fileNav.Should().NotBeNull(); + fileNav.Model.RelativePath.OptionalWindowsReplace().Should().Be("index.md"); } } diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs index d80533e41..b28cbd287 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs @@ -173,6 +173,6 @@ public void EmitsError() Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(1); Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); Collector.Diagnostics.Should() - .OnlyContain(d => d.Message.Contains("cyclical include detected")); + .Contain(d => d.Message.Contains("cyclical include detected")); } } diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index 936cf66de..3df2faf91 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -119,7 +119,7 @@ protected InlineTest( }; var linkResolver = new TestCrossLinkResolver(); Set = new DocumentationSet(context, logger, linkResolver); - File = Set.DocumentationFileLookup(FileSystem.FileInfo.New("docs/index.md")) as MarkdownFile ?? throw new NullReferenceException(); + File = Set.TryFindDocument(FileSystem.FileInfo.New("docs/index.md")) as MarkdownFile ?? throw new NullReferenceException(); Html = default!; //assigned later Document = default!; } @@ -132,7 +132,7 @@ public virtual async ValueTask InitializeAsync() await Set.ResolveDirectoryTree(TestContext.Current.CancellationToken); - Document = await File.ParseFullAsync(TestContext.Current.CancellationToken); + Document = await File.ParseFullAsync(Set.TryFindDocumentByRelativePath, TestContext.Current.CancellationToken); var html = MarkdownFile.CreateHtml(Document).AsSpan(); var find = "\n"; var start = html.IndexOf(find, StringComparison.Ordinal); diff --git a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs index cef4161bc..6986e38cb 100644 --- a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs +++ b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs @@ -21,6 +21,8 @@ public static void GenerateDocSetYaml(this MockFileSystem fileSystem, IDirectory .EnumerateFiles(root.FullName, "*.md", SearchOption.AllDirectories); foreach (var markdownFile in markdownFiles) { + if (markdownFile.Contains("_snippet")) + continue; var relative = fileSystem.Path.GetRelativePath(root.FullName, markdownFile); yaml.WriteLine($" - file: {relative}"); } diff --git a/tests/Elastic.Markdown.Tests/TestDiagnosticsCollector.cs b/tests/Elastic.Markdown.Tests/TestDiagnosticsCollector.cs index f5b911d70..e7a65736d 100644 --- a/tests/Elastic.Markdown.Tests/TestDiagnosticsCollector.cs +++ b/tests/Elastic.Markdown.Tests/TestDiagnosticsCollector.cs @@ -24,5 +24,16 @@ public class TestDiagnosticsCollector(ITestOutputHelper output) public IReadOnlyCollection Diagnostics => _diagnostics; - protected override void HandleItem(Diagnostic diagnostic) => _diagnostics.Add(diagnostic); + /// + public override void Write(Diagnostic diagnostic) + { + IncrementSeverityCount(diagnostic); + _diagnostics.Add(diagnostic); + } + + /// + public override DiagnosticsCollector StartAsync(Cancel ctx) => this; + + /// + public override Task StopAsync(Cancel cancellationToken) => Task.CompletedTask; } diff --git a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs new file mode 100644 index 000000000..24a71445d --- /dev/null +++ b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs @@ -0,0 +1,350 @@ +// 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 Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Navigation.Assembler; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Leaf; +using Elastic.Documentation.Navigation.Isolated.Node; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Assembler; + +public class ComplexSiteNavigationTests(ITestOutputHelper output) +{ + [Fact] + public void ComplexNavigationWithMultipleNestedTocsAppliesPathPrefixToRootUrls() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: observability:// + path_prefix: /serverless/observability + - toc: serverless-search:// + path_prefix: /serverless/search + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment + - toc: platform://cloud-guide + path_prefix: /platform/cloud + - toc: elasticsearch-reference:// + path_prefix: /elasticsearch/reference + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create all DocumentationSetNavigation instances + var checkoutDir = fileSystem.DirectoryInfo.New("/checkouts/current"); + var repositories = checkoutDir.GetDirectories(); + + var documentationSets = new List(); + + foreach (var repo in repositories) + { + var context = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, repo.FullName, output); + + var docsetPath = fileSystem.File.Exists($"{repo.FullName}/docs/docset.yml") + ? $"{repo.FullName}/docs/docset.yml" + : $"{repo.FullName}/docs/_docset.yml"; + + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + documentationSets.Add(navigation); + } + + var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/observability", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + // Verify we have all expected top-level items + siteNavigation.NavigationItems.Should().HaveCount(4); + + // Test 1: Observability - verify root URL has path prefix + var observability = siteNavigation.NavigationItems.ElementAt(0) as INodeNavigationItem; + observability.Should().NotBeNull(); + observability.Url.Should().Be("/serverless/observability"); + observability.NavigationTitle.Should().Be(observability.Index.NavigationTitle); + + // Test 2: Serverless Search - verify root URL has path prefix + var search = siteNavigation.NavigationItems.ElementAt(1); + search.Should().NotBeNull(); + search.Url.Should().Be("/serverless/search"); + + // Test 3: Platform - verify root URL has path prefix + var platform = siteNavigation.NavigationItems.ElementAt(2) as INodeNavigationItem; + platform.Should().NotBeNull(); + platform.Url.Should().Be("/platform"); + platform.NavigationItems.Should().HaveCount(2, "platform should only show the two nested TOCs as children"); + + // Verify nested TOC URLs have their specified path prefixes + var deploymentGuide = platform.NavigationItems.ElementAt(0) as INodeNavigationItem; + deploymentGuide.Should().NotBeNull(); + deploymentGuide.Url.Should().Be("/platform/deployment"); + deploymentGuide.NavigationTitle.Should().Be(deploymentGuide.Index.NavigationTitle); + + var cloudGuide = platform.NavigationItems.ElementAt(1); + cloudGuide.Should().NotBeNull(); + cloudGuide.Url.Should().Be("/platform/cloud"); + cloudGuide.NavigationTitle.Should().Be("Cloud Guide"); + + // Test 4: Elasticsearch Reference - verify root URL has path prefix + var elasticsearch = siteNavigation.NavigationItems.ElementAt(3) as INodeNavigationItem; + elasticsearch.Should().NotBeNull(); + elasticsearch.Url.Should().Be("/elasticsearch/reference"); + elasticsearch.NavigationItems.Should().HaveCount(2, "elasticsearch should have read its toc"); + + // rest-apis is a folder (not a TOC) + var restApis = elasticsearch.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + restApis.Url.Should().Be("/elasticsearch/reference/rest-apis"); + restApis.NavigationItems.Should().HaveCount(2, "rest-apis folder should have 2 files"); + + // Verify the file inside the folder has the correct path prefix + var documentApisFile = restApis.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + documentApisFile.Url.Should().Be("/elasticsearch/reference/rest-apis/document-apis"); + documentApisFile.NavigationTitle.Should().Be("Document APIs"); + } + + [Fact] + public void DeeplyNestedNavigationMaintainsPathPrefixThroughoutHierarchy() + { + // language=YAML - test without specifying children for nested TOCs + var siteNavYaml = """ + toc: + - toc: platform:// + path_prefix: /docs/platform + children: + - toc: platform://deployment-guide + path_prefix: /docs/platform/deployment-guide + - toc: platform://cloud-guide + path_prefix: /docs/platform/cloud-guide + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + + var documentationSets = new List + { + new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) + }; + + var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + var platform = siteNavigation.NavigationItems.First() as INodeNavigationItem; + platform.Should().NotBeNull(); + platform.Url.Should().Be("/docs/platform"); + + // Platform should have its children (deployment-guide, cloud-guide) + platform.NavigationItems.Should().HaveCount(2); + + // Find the deployment-guide TOC (it's the second item after index) + var deploymentGuide = platform.NavigationItems.ElementAt(0) as INodeNavigationItem; + deploymentGuide.Should().NotBeNull(); + deploymentGuide.Should().BeOfType>(); + deploymentGuide.Url.Should().StartWith("/docs/platform"); + + // Walk through the entire tree and verify every single URL starts with a path prefix + var allUrls = CollectAllUrls(platform.NavigationItems); + allUrls.Should().NotBeEmpty(); + allUrls.Should().OnlyContain(url => url.StartsWith("/docs/platform/"), + "all URLs in platform should start with /docs/platform"); + } + + [Fact] + public void FileNavigationLeafUrlsReflectPathPrefixInDeeplyNestedStructures() + { + // language=YAML - don't specify children so we can access the actual file leaves + var siteNavYaml = """ + toc: + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment-guide + - toc: platform://cloud-guide + path_prefix: /platform/cloud-guide + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + + var documentationSets = new List + { + new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) + }; + + var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + var platform = siteNavigation.NavigationItems.First() as INodeNavigationItem; + platform.Should().NotBeNull(); + + // Platform should have its children including deployment-guide TOC + platform.NavigationItems.Should().HaveCount(2); + + // Get deployment-guide TOC + var deploymentGuide = platform.NavigationItems.ElementAt(0) as INodeNavigationItem; + deploymentGuide.Should().NotBeNull(); + deploymentGuide.Should().BeOfType>(); + + // Find all FileNavigationLeaf items recursively + var fileLeaves = CollectAllFileLeaves(deploymentGuide.NavigationItems); + fileLeaves.Should().NotBeEmpty("deployment-guide should contain file leaves"); + + // Verify every single file leaf has the correct path prefix + foreach (var fileLeaf in fileLeaves) + { + fileLeaf.Url.Should().StartWith("/platform", + $"file '{fileLeaf.NavigationTitle}' should have URL starting with /platform but got '{fileLeaf.Url}'"); + } + + // Verify at least one specific file to ensure we're testing real data + var indexFile = fileLeaves.OfType>() + .FirstOrDefault(f => f.FileInfo.FullName.EndsWith(".md", StringComparison.OrdinalIgnoreCase)); + indexFile.Should().NotBeNull(); + indexFile.Url.Should().StartWith("/platform"); + } + + [Fact] + public void FolderNavigationWithinNestedTocsHasCorrectPathPrefix() + { + // language=YAML - don't specify children so we can access the actual folders + var siteNavYaml = """ + toc: + - toc: platform:// + path_prefix: /platform/cloud + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment-guide + - toc: platform://cloud-guide + path_prefix: /platform/cloud-guide + + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + + var documentationSets = new List + { + new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) + }; + + var siteContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + var platform = siteNavigation.NavigationItems.First() as INodeNavigationItem; + platform.Should().NotBeNull(); + + // Platform should have its children including cloud-guide TOC + platform.NavigationItems.Should().HaveCount(2); + + // Get cloud-guide TOC (third item after deployment-guide) + var cloudGuide = platform.NavigationItems.ElementAt(1) as INodeNavigationItem; + cloudGuide.Should().NotBeNull(); + cloudGuide.Should().BeOfType>(); + + // cloud-guide should have folders (index, aws, azure) + var folders = cloudGuide.NavigationItems + .OfType>() + .ToList(); + + folders.Should().NotBeEmpty("cloud-guide should contain folders"); + + // Verify each folder and all its contents have a correct path prefix + foreach (var folder in folders) + { + folder.Url.Should().StartWith("/platform/cloud", + $"folder '{folder.NavigationTitle}' should have URL starting with /platform/cloud"); + + // Verify all items within the folder + AssertAllUrlsStartWith(folder.NavigationItems, "/platform/cloud"); + + // Verify specific file leaves within the folder + var filesInFolder = CollectAllFileLeaves(folder.NavigationItems); + foreach (var file in filesInFolder) + { + file.Url.Should().StartWith("/platform/cloud", + $"file '{file.NavigationTitle}' in folder '{folder.NavigationTitle}' should have URL starting with /platform/cloud"); + } + } + } + + /// + /// Helper method to recursively assert all URLs start with a given prefix + /// + private static void AssertAllUrlsStartWith(IEnumerable items, string expectedPrefix) + { + foreach (var item in items) + { + item.Url.Should().StartWith(expectedPrefix, + $"item '{item.NavigationTitle}' should have URL starting with '{expectedPrefix}' but got '{item.Url}'"); + + if (item is INodeNavigationItem nodeItem) + AssertAllUrlsStartWith(nodeItem.NavigationItems, expectedPrefix); + } + } + + /// + /// Helper method to collect all URLs recursively + /// + private static List CollectAllUrls(IEnumerable items) + { + var urls = new List(); + + foreach (var item in items) + { + urls.Add(item.Url); + + if (item is INodeNavigationItem nodeItem) + urls.AddRange(CollectAllUrls(nodeItem.NavigationItems)); + } + + return urls; + } + + /// + /// Helper method to collect all FileNavigationLeaf items recursively + /// + private static List> CollectAllFileLeaves(IEnumerable items) + { + var fileLeaves = new List>(); + + foreach (var item in items) + { + switch (item) + { + case ILeafNavigationItem fileLeaf: + fileLeaves.Add(fileLeaf); + break; + case INodeNavigationItem node: + fileLeaves.Add(node.Index); + fileLeaves.AddRange(CollectAllFileLeaves(node.NavigationItems)); + break; + } + } + + return fileLeaves; + } +} diff --git a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs new file mode 100644 index 000000000..d22239a3b --- /dev/null +++ b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs @@ -0,0 +1,117 @@ +// 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 Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Node; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Assembler; + +public class IdentifierCollectionTests(ITestOutputHelper output) +{ + [Fact] + public void DocumentationSetNavigationCollectsRootIdentifier() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test platform repository + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + // Root identifier should be :// + platformNav.Identifier.Should().Be(new Uri("platform://")); + platformNav.TableOfContentNodes.Keys.Should().Contain(new Uri("platform://")); + } + + [Fact] + public void DocumentationSetNavigationCollectsNestedTocIdentifiers() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test platform repository with nested TOCs + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, platformContext.ConfigurationPath, fileSystem); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + // Should collect identifiers from nested TOCs + platformNav.TableOfContentNodes.Keys.Should().Contain( + [ + new Uri("platform://"), + new Uri("platform://deployment-guide"), + new Uri("platform://cloud-guide") + ]); + + platformNav.TableOfContentNodes.Should().HaveCount(3); + } + + [Fact] + public void DocumentationSetNavigationWithSimpleStructure() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test observability repository (no nested TOCs) + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + // Should only have root identifier + observabilityNav.TableOfContentNodes.Keys.Should().Contain(new Uri("observability://")); + observabilityNav.TableOfContentNodes.Should().HaveCount(1); + } + + [Fact] + public void TableOfContentsNavigationHasCorrectIdentifier() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test platform repository with nested TOCs + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + // Get the deployment-guide TOC + var deploymentGuide = platformNav.NavigationItems.ElementAt(0) as TableOfContentsNavigation; + deploymentGuide.Should().NotBeNull(); + deploymentGuide.Identifier.Should().Be(new Uri("platform://deployment-guide")); + + // Get the cloud-guide TOC + var cloudGuide = platformNav.NavigationItems.ElementAt(1) as TableOfContentsNavigation; + cloudGuide.Should().NotBeNull(); + cloudGuide.Identifier.Should().Be(new Uri("platform://cloud-guide")); + } + + [Fact] + public void MultipleDocumentationSetsHaveDistinctIdentifiers() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create multiple documentation sets + var platformContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve( + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + var observabilityContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.LoadAndResolve( + observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + // Each should have its own set of identifiers + platformNav.TableOfContentNodes.Keys.Should().NotIntersectWith(observabilityNav.TableOfContentNodes.Keys); + + // Platform should have repository name in its identifiers + platformNav.TableOfContentNodes.Keys.Should().AllSatisfy(id => id.Scheme.Should().Be("platform")); + + // Observability should have repository name in its identifiers + observabilityNav.TableOfContentNodes.Keys.Should().AllSatisfy(id => id.Scheme.Should().Be("observability")); + } +} diff --git a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs new file mode 100644 index 000000000..0bd92f014 --- /dev/null +++ b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs @@ -0,0 +1,600 @@ +// 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 Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Navigation.Assembler; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Leaf; +using Elastic.Documentation.Navigation.Isolated.Node; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Assembler; + +public class SiteDocumentationSetsTests(ITestOutputHelper output) +{ + [Fact] + public void CreatesDocumentationSetNavigationsFromCheckoutFolders() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Discover all repositories in /checkouts/current + var checkoutDir = fileSystem.DirectoryInfo.New("/checkouts/current"); + var repositories = checkoutDir.GetDirectories(); + + repositories.Should().HaveCount(5); + repositories.Select(r => r.Name).Should().Contain( + [ + "observability", + "serverless-search", + "serverless-security", + "platform", + "elasticsearch-reference" + ]); + var collector = new TestDiagnosticsCollector(output); + _ = collector.StartAsync(TestContext.Current.CancellationToken); + + // Create DocumentationSetNavigation for each repository + var documentationSets = new List>(); + + foreach (var repo in repositories) + { + var context = SiteNavigationTestFixture.CreateContext(fileSystem, repo.FullName, output, collector); + + // Read the docset file + var docsetPath = fileSystem.File.Exists($"{repo.FullName}/docs/docset.yml") + ? $"{repo.FullName}/docs/docset.yml" + : $"{repo.FullName}/docs/_docset.yml"; + + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + documentationSets.Add(navigation); + } + + documentationSets.Should().HaveCount(5); + + // Verify each documentation set has navigation items + foreach (var docSet in documentationSets) + docSet.NavigationItems.Should().NotBeEmpty(); + + collector.Errors.Should().Be(0, "there should be no errors when loading observability documentation set"); + collector.Warnings.Should().Be(0, "there should be no warnings when loading observability documentation set"); + } + + [Fact] + public void SiteNavigationIntegratesWithDocumentationSets() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: observability:// + path_prefix: /serverless/observability + - toc: serverless-search:// + path_prefix: /serverless/search + - toc: serverless-security:// + path_prefix: /serverless/security + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create DocumentationSetNavigation instances + var documentationSets = new List(); + + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + documentationSets.Add(new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance)); + + var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), fileSystem); + documentationSets.Add(new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance)); + + var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); + var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), fileSystem); + documentationSets.Add(new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance)); + + // Create site navigation context (using any repository's filesystem) + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + siteNavigation.Should().NotBeNull(); + siteNavigation.NavigationItems.Should().HaveCount(3); + + var observability = siteNavigation.NavigationItems.ElementAt(0); + observability.Url.Should().Be("/serverless/observability"); + observability.NavigationTitle.Should().NotBeNullOrEmpty(); + + var search = siteNavigation.NavigationItems.ElementAt(1); + search.Url.Should().Be("/serverless/search"); + + var security = siteNavigation.NavigationItems.ElementAt(2); + security.Url.Should().Be("/serverless/security"); + } + + [Fact] + public void SiteNavigationWithNestedTocs() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment + - toc: platform://cloud-guide + path_prefix: /platform/cloud + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create DocumentationSetNavigation for platform + var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + platformNav.Url.Should().Be("/"); + platformNav.Index.Url.Should().Be("/"); + platformNav.NavigationItems.ElementAt(0).Url.Should().Be("/deployment-guide"); + platformNav.NavigationItems.ElementAt(1).Url.Should().Be("/cloud-guide"); + + var documentationSets = new List { platformNav }; + + var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + siteNavigation.NavigationItems.Should().HaveCount(1); + + var platform = siteNavigation.NavigationItems.First() as INodeNavigationItem; + platform.Should().NotBeNull(); + platform.Url.Should().Be("/platform"); + platform.NavigationItems.Should().HaveCount(2); + + var deployment = platform.NavigationItems.ElementAt(0); + deployment.Url.Should().Be("/platform/deployment"); + + var cloud = platform.NavigationItems.ElementAt(1); + cloud.Url.Should().Be("/platform/cloud"); + } + + [Fact] + public void SiteNavigationWithAllRepositories() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: observability:// + path_prefix: /serverless/observability + - toc: serverless-search:// + path_prefix: /serverless/search + - toc: serverless-security:// + path_prefix: /serverless/security + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment + - toc: platform://cloud-guide + path_prefix: /platform/cloud + - toc: elasticsearch-reference:// + path_prefix: /elasticsearch/reference + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create all DocumentationSetNavigation instances + var checkoutDir = fileSystem.DirectoryInfo.New("/checkouts/current"); + var repositories = checkoutDir.GetDirectories(); + + var documentationSets = new List(); + + foreach (var repo in repositories) + { + var context = SiteNavigationTestFixture.CreateContext(fileSystem, repo.FullName, output); + + var docsetPath = fileSystem.File.Exists($"{repo.FullName}/docs/docset.yml") + ? $"{repo.FullName}/docs/docset.yml" + : $"{repo.FullName}/docs/_docset.yml"; + + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + documentationSets.Add(navigation); + } + + var siteContext = SiteNavigationTestFixture.CreateContext( + fileSystem, "/checkouts/current/observability", output); + + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + siteNavigation.Should().NotBeNull(); + siteNavigation.NavigationItems.Should().HaveCount(5); + + // Verify top-level items + var observability = siteNavigation.NavigationItems.ElementAt(0); + observability.Url.Should().Be("/serverless/observability"); + + var search = siteNavigation.NavigationItems.ElementAt(1); + search.Url.Should().Be("/serverless/search"); + + var security = siteNavigation.NavigationItems.ElementAt(2); + security.Url.Should().Be("/serverless/security"); + + var platform = siteNavigation.NavigationItems.ElementAt(3) as INodeNavigationItem; + platform.Should().NotBeNull(); + platform.Url.Should().Be("/platform"); + platform.NavigationItems.Should().HaveCount(2); + + var elasticsearch = siteNavigation.NavigationItems.ElementAt(4); + elasticsearch.Url.Should().Be("/elasticsearch/reference"); + } + + [Fact] + public void DocumentationSetNavigationHasCorrectStructure() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test observability repository structure + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + observabilityNav.NavigationTitle.Should().Be(observabilityNav.NavigationTitle); + observabilityNav.NavigationItems.Should().HaveCount(2); // index.md, getting-started folder, monitoring folder + + var indexFile = observabilityNav.Index; + indexFile.Should().BeOfType>(); + indexFile.Url.Should().Be("/"); + + var gettingStarted = observabilityNav.NavigationItems.ElementAt(0); + gettingStarted.Should().BeOfType>(); + var gettingStartedFolder = (FolderNavigation)gettingStarted; + gettingStartedFolder.Index.Should().NotBeNull(); //quick-start.md + gettingStartedFolder.NavigationItems.Should().HaveCount(1); // installation.md + + var monitoring = observabilityNav.NavigationItems.ElementAt(1); + monitoring.Should().BeOfType>(); + var monitoringFolder = (FolderNavigation)monitoring; + monitoringFolder.NavigationItems.Should().HaveCount(3); // logs.md, metrics.md, traces.md + } + + [Fact] + public void DocumentationSetWithNestedTocs() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test platform repository with nested TOCs + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + platformNav.NavigationTitle.Should().Be("Platform"); + platformNav.NavigationItems.Should().HaveCount(2); // deployment-guide TOC, cloud-guide TOC + + var indexFile = platformNav.Index; + indexFile.Should().BeOfType>(); + indexFile.Url.Should().Be("/"); + + var deploymentGuide = platformNav.NavigationItems.ElementAt(0); + deploymentGuide.Should().BeOfType>(); + deploymentGuide.Url.Should().Be("/deployment-guide"); + var deploymentToc = (TableOfContentsNavigation)deploymentGuide; + deploymentToc.NavigationItems.Should().HaveCount(1); // self-managed folder + + var cloudGuide = platformNav.NavigationItems.ElementAt(1); + cloudGuide.Should().BeOfType>(); + cloudGuide.Url.Should().Be("/cloud-guide"); + var cloudToc = (TableOfContentsNavigation)cloudGuide; + cloudToc.NavigationItems.Should().HaveCount(2); // aws folder, azure folder + } + + [Fact] + public void DocumentationSetWithUnderscoreDocset() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Test serverless-security repository with _docset.yml + var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); + var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), fileSystem); + var securityNav = new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance); + + securityNav.NavigationTitle.Should().Be("Serverless Security"); + securityNav.NavigationItems.Should().HaveCount(2); // authentication folder, authorization folder + + var authentication = securityNav.NavigationItems.ElementAt(0); + authentication.Should().BeOfType>(); + var authenticationFolder = (FolderNavigation)authentication; + authenticationFolder.NavigationItems.Should().HaveCount(2); // api-keys.md, oauth.md + + var authorization = securityNav.NavigationItems.ElementAt(1); + authorization.Should().BeOfType>(); + var authorizationFolder = (FolderNavigation)authorization; + authorizationFolder.NavigationItems.Should().HaveCount(1); // rbac.md + } + + [Fact] + public void SiteNavigationAppliesPathPrefixToAllUrls() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: observability:// + path_prefix: /serverless/observability + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + // Verify root URL has path prefix + var root = siteNavigation.NavigationItems.First(); + root.Url.Should().StartWith("/serverless/observability"); + + // Verify all nested items also have the path prefix + if (root is INodeNavigationItem nodeItem) + { + foreach (var item in nodeItem.NavigationItems) + item.Url.Should().StartWith("/serverless/observability"); + } + } + + [Fact] + public void SiteNavigationWithNestedTocsAppliesCorrectPathPrefixes() + { + // language=yaml + var siteNavYaml = """ + toc: + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment + - toc: platform://cloud-guide + path_prefix: /platform/cloud + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var documentationSets = new List { new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) }; + + var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + var platform = siteNavigation.NavigationItems.First() as INodeNavigationItem; + platform.Should().NotBeNull(); + platform.Url.Should().Be("/platform"); + + // Verify child TOCs have their specific path prefixes + var deployment = platform.NavigationItems.ElementAt(0); + deployment.Url.Should().StartWith("/platform/deployment"); + + var cloud = platform.NavigationItems.ElementAt(1); + cloud.Url.Should().StartWith("/platform/cloud"); + } + + [Fact] + public void SiteNavigationRequiresPathPrefix() + { + // language=yaml - missing path_prefix + var siteNavYaml = """ + toc: + - toc: observability:// + """; + + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var siteNavigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + // navigation will still be build + siteNavigation.NavigationItems.Should().NotBeEmpty(); + + var toc = siteNavigation.NavigationItems.First() as DocumentationSetNavigation; + toc.Should().NotBeNull(); + toc.HomeProvider.PathPrefix.Should().Be("/bad-mapping-observability"); + // toc has no `path_prefix` so it will use a default ugly one to avoid clashes and emit an error + toc.Url.Should().Be("/bad-mapping-observability"); + } + + [Fact] + public void ObservabilityDocumentationSetNavigationHasNoDiagnostics() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + + var docsetPath = fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + + // Assert navigation was created successfully + navigation.Should().NotBeNull(); + navigation.NavigationItems.Should().NotBeEmpty(); + + // Assert no diagnostic errors or warnings + context.Collector.Errors.Should().Be(0, "there should be no errors when loading observability documentation set"); + context.Collector.Warnings.Should().Be(0, "there should be no warnings when loading observability documentation set"); + } + + [Fact] + public void ServerlessSearchDocumentationSetNavigationHasNoDiagnostics() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); + + var docsetPath = fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + + // Assert navigation was created successfully + navigation.Should().NotBeNull(); + navigation.NavigationItems.Should().NotBeEmpty(); + + // Assert no diagnostic errors or warnings + context.Collector.Errors.Should().Be(0, "there should be no errors when loading serverless-search documentation set"); + context.Collector.Warnings.Should().Be(0, "there should be no warnings when loading serverless-search documentation set"); + } + + [Fact] + public void ServerlessSecurityDocumentationSetNavigationHasNoDiagnostics() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); + + var docsetPath = fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + + // Assert navigation was created successfully + navigation.Should().NotBeNull(); + navigation.NavigationItems.Should().NotBeEmpty(); + + // Assert no diagnostic errors or warnings + context.Collector.Errors.Should().Be(0, "there should be no errors when loading serverless-security documentation set"); + context.Collector.Warnings.Should().Be(0, "there should be no warnings when loading serverless-security documentation set"); + } + + [Fact] + public void PlatformDocumentationSetNavigationHasNoDiagnostics() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + + var docsetPath = fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + + // Assert navigation was created successfully + navigation.Should().NotBeNull(); + navigation.NavigationItems.Should().NotBeEmpty(); + + // Assert no diagnostic errors or warnings + context.Collector.Errors.Should().Be(0, "there should be no errors when loading platform documentation set"); + context.Collector.Warnings.Should().Be(0, "there should be no warnings when loading platform documentation set"); + } + + [Fact] + public void ElasticsearchReferenceDocumentationSetNavigationHasNoDiagnostics() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/elasticsearch-reference", output); + + var docsetPath = fileSystem.FileInfo.New("/checkouts/current/elasticsearch-reference/docs/docset.yml"); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + + // Assert navigation was created successfully + navigation.Should().NotBeNull(); + navigation.NavigationItems.Should().NotBeEmpty(); + + // Assert no diagnostic errors or warnings + context.Collector.Errors.Should().Be(0, "there should be no errors when loading elasticsearch-reference documentation set"); + context.Collector.Warnings.Should().Be(0, "there should be no warnings when loading elasticsearch-reference documentation set"); + } + + [Fact] + public void AllDocumentationSetsHaveNoDiagnostics() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + var checkoutDir = fileSystem.DirectoryInfo.New("/checkouts/current"); + var repositories = checkoutDir.GetDirectories(); + + repositories.Should().HaveCount(5); + + foreach (var repo in repositories) + { + var context = SiteNavigationTestFixture.CreateContext(fileSystem, repo.FullName, output); + + // Read the docset file + var docsetPath = fileSystem.File.Exists($"{repo.FullName}/docs/docset.yml") + ? $"{repo.FullName}/docs/docset.yml" + : $"{repo.FullName}/docs/_docset.yml"; + + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + + // Assert navigation was created successfully + navigation.Should().NotBeNull($"navigation for {repo.Name} should be created"); + navigation.NavigationItems.Should().NotBeEmpty($"navigation for {repo.Name} should have items"); + + // Assert no diagnostic errors or warnings + context.Collector.Errors.Should().Be(0, $"there should be no errors when loading {repo.Name} documentation set"); + context.Collector.Warnings.Should().Be(0, $"there should be no warnings when loading {repo.Name} documentation set"); + } + } + + [Fact] + public void DocumentationSetNavigationWithNestedTocsHasNoDiagnostics() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + + var docsetPath = fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + + // Assert navigation was created successfully with nested TOCs + navigation.Should().NotBeNull(); + navigation.NavigationItems.Should().HaveCount(2); // deployment-guide TOC, cloud-guide TOC (index.md is in Index property) + + var deploymentGuide = navigation.NavigationItems.ElementAt(0); + deploymentGuide.Should().BeOfType>(); + + var cloudGuide = navigation.NavigationItems.ElementAt(1); + cloudGuide.Should().BeOfType>(); + + // Assert no diagnostic errors or warnings + context.Collector.Errors.Should().Be(0, "there should be no errors when loading platform documentation set with nested TOCs"); + context.Collector.Warnings.Should().Be(0, "there should be no warnings when loading platform documentation set with nested TOCs"); + } + + [Fact] + public void DocumentationSetNavigationWithFoldersHasNoDiagnostics() + { + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + + var docsetPath = fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, fileSystem); + + var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); + + // Assert navigation was created successfully with folders + navigation.Should().NotBeNull(); + navigation.NavigationItems.Should().HaveCount(2); // index.md, getting-started folder, monitoring folder + + var gettingStarted = navigation.NavigationItems.ElementAt(0); + gettingStarted.Should().BeOfType>(); + + var monitoring = navigation.NavigationItems.ElementAt(1); + monitoring.Should().BeOfType>(); + + // Assert no diagnostic errors or warnings + context.Collector.Errors.Should().Be(0, "there should be no errors when loading documentation set with folders"); + context.Collector.Warnings.Should().Be(0, "there should be no warnings when loading documentation set with folders"); + } +} diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs new file mode 100644 index 000000000..bd778513a --- /dev/null +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs @@ -0,0 +1,251 @@ +// 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; + +namespace Elastic.Documentation.Navigation.Tests.Assembler; + +public static class SiteNavigationTestFixture +{ + public static MockFileSystem CreateMultiRepositoryFileSystem() + { + var fileSystem = new MockFileSystem(); + + // Repository 1: serverless-observability + SetupServerlessObservabilityRepository(fileSystem); + + // Repository 2: serverless-search + SetupServerlessSearchRepository(fileSystem); + + // Repository 3: serverless-security + SetupServerlessSecurityRepository(fileSystem); + + // Repository 4: platform + SetupPlatformRepository(fileSystem); + + // Repository 5: elasticsearch-reference + SetupElasticsearchReferenceRepository(fileSystem); + + return fileSystem; + } + + private static void SetupServerlessObservabilityRepository(MockFileSystem fileSystem) + { + var baseDir = "/checkouts/current/observability"; + fileSystem.AddDirectory(baseDir); + + // Add docset.yml + // language=yaml + var docsetYaml = """ + project: serverless-observability + toc: + - file: index.md + - folder: getting-started + children: + - file: quick-start.md + - file: installation.md + - folder: monitoring + children: + - file: index.md + - file: logs.md + - file: metrics.md + - file: traces.md + """; + fileSystem.AddFile($"{baseDir}/docs/docset.yml", new MockFileData(docsetYaml)); + + // Add markdown files + fileSystem.AddFile($"{baseDir}/docs/index.md", new MockFileData("# Serverless Observability")); + fileSystem.AddFile($"{baseDir}/docs/getting-started/quick-start.md", new MockFileData("# Quick Start")); + fileSystem.AddFile($"{baseDir}/docs/getting-started/installation.md", new MockFileData("# Installation")); + fileSystem.AddFile($"{baseDir}/docs/monitoring/index.md", new MockFileData("# Monitoring")); + fileSystem.AddFile($"{baseDir}/docs/monitoring/logs.md", new MockFileData("# Logs")); + fileSystem.AddFile($"{baseDir}/docs/monitoring/metrics.md", new MockFileData("# Metrics")); + fileSystem.AddFile($"{baseDir}/docs/monitoring/traces.md", new MockFileData("# Traces")); + } + + private static void SetupServerlessSearchRepository(MockFileSystem fileSystem) + { + var baseDir = "/checkouts/current/serverless-search"; + fileSystem.AddDirectory(baseDir); + + // Add docset.yml + // language=yaml + var docsetYaml = """ + project: serverless-search + toc: + - file: index.md + - folder: indexing + children: + - file: index.md + - file: documents.md + - file: bulk-api.md + - folder: searching + children: + - file: index.md + - file: query-dsl.md + """; + fileSystem.AddFile($"{baseDir}/docs/docset.yml", new MockFileData(docsetYaml)); + + // Add markdown files + fileSystem.AddFile($"{baseDir}/docs/index.md", new MockFileData("# Serverless Search")); + fileSystem.AddFile($"{baseDir}/docs/indexing/index.md", new MockFileData("# Indexing")); + fileSystem.AddFile($"{baseDir}/docs/indexing/documents.md", new MockFileData("# Documents")); + fileSystem.AddFile($"{baseDir}/docs/indexing/bulk-api.md", new MockFileData("# Bulk API")); + fileSystem.AddFile($"{baseDir}/docs/searching/index.md", new MockFileData("# Searching")); + fileSystem.AddFile($"{baseDir}/docs/searching/query-dsl.md", new MockFileData("# Query DSL")); + } + + private static void SetupServerlessSecurityRepository(MockFileSystem fileSystem) + { + var baseDir = "/checkouts/current/serverless-security"; + fileSystem.AddDirectory(baseDir); + + // Add docset.yml with underscore prefix + // language=yaml + var docsetYaml = """ + project: serverless-security + toc: + - file: index.md + - folder: authentication + children: + - file: index.md + - file: api-keys.md + - file: oauth.md + - folder: authorization + children: + - file: index.md + - file: rbac.md + """; + fileSystem.AddFile($"{baseDir}/docs/_docset.yml", new MockFileData(docsetYaml)); + + // Add markdown files + fileSystem.AddFile($"{baseDir}/docs/index.md", new MockFileData("# Serverless Security")); + fileSystem.AddFile($"{baseDir}/docs/authentication/index.md", new MockFileData("# Authentication")); + fileSystem.AddFile($"{baseDir}/docs/authentication/api-keys.md", new MockFileData("# API Keys")); + fileSystem.AddFile($"{baseDir}/docs/authentication/oauth.md", new MockFileData("# OAuth")); + fileSystem.AddFile($"{baseDir}/docs/authorization/index.md", new MockFileData("# Authorization")); + fileSystem.AddFile($"{baseDir}/docs/authorization/rbac.md", new MockFileData("# RBAC")); + } + + private static void SetupPlatformRepository(MockFileSystem fileSystem) + { + var baseDir = "/checkouts/current/platform"; + fileSystem.AddDirectory(baseDir); + + // Add docset.yml + // language=yaml + var docsetYaml = """ + project: platform + toc: + - file: index.md + - toc: deployment-guide + - toc: cloud-guide + """; + fileSystem.AddFile($"{baseDir}/docs/docset.yml", new MockFileData(docsetYaml)); + fileSystem.AddFile($"{baseDir}/docs/index.md", new MockFileData("# Platform")); + + // Deployment guide sub-TOC + var deploymentBaseDir = $"{baseDir}/docs/deployment-guide"; + fileSystem.AddDirectory(deploymentBaseDir); + // language=yaml + var deploymentTocYaml = """ + toc: + - file: index.md + - folder: self-managed + children: + - file: installation.md + - file: configuration.md + """; + fileSystem.AddFile($"{deploymentBaseDir}/toc.yml", new MockFileData(deploymentTocYaml)); + fileSystem.AddFile($"{deploymentBaseDir}/index.md", new MockFileData("# Deployment Guide")); + fileSystem.AddFile($"{deploymentBaseDir}/self-managed/installation.md", new MockFileData("# Installation")); + fileSystem.AddFile($"{deploymentBaseDir}/self-managed/configuration.md", new MockFileData("# Configuration")); + + // Cloud guide sub-TOC + var cloudBaseDir = $"{baseDir}/docs/cloud-guide"; + fileSystem.AddDirectory(cloudBaseDir); + // language=yaml + var cloudTocYaml = """ + toc: + - file: index.md + - folder: aws + children: + - file: setup.md + - folder: azure + children: + - file: setup.md + """; + fileSystem.AddFile($"{cloudBaseDir}/toc.yml", new MockFileData(cloudTocYaml)); + fileSystem.AddFile($"{cloudBaseDir}/index.md", new MockFileData("# Cloud Guide")); + fileSystem.AddFile($"{cloudBaseDir}/aws/setup.md", new MockFileData("# AWS Setup")); + fileSystem.AddFile($"{cloudBaseDir}/azure/setup.md", new MockFileData("# Azure Setup")); + } + + private static void SetupElasticsearchReferenceRepository(MockFileSystem fileSystem) + { + var baseDir = "/checkouts/current/elasticsearch-reference"; + fileSystem.AddDirectory(baseDir); + + // Add docset.yml + // language=yaml + var docsetYaml = """ + project: elasticsearch-reference + toc: + - file: index.md + - folder: rest-apis + children: + - file: index.md + - file: document-apis.md + - file: search-apis.md + - folder: query-dsl + children: + - file: index.md + - file: term-queries.md + - file: full-text-queries.md + """; + fileSystem.AddFile($"{baseDir}/docs/docset.yml", new MockFileData(docsetYaml)); + + // Add markdown files + fileSystem.AddFile($"{baseDir}/docs/index.md", new MockFileData("# Elasticsearch Reference")); + fileSystem.AddFile($"{baseDir}/docs/rest-apis/index.md", new MockFileData("# REST APIs")); + fileSystem.AddFile($"{baseDir}/docs/rest-apis/document-apis.md", new MockFileData("# Document APIs")); + fileSystem.AddFile($"{baseDir}/docs/rest-apis/search-apis.md", new MockFileData("# Search APIs")); + fileSystem.AddFile($"{baseDir}/docs/query-dsl/index.md", new MockFileData("# Query DSL")); + fileSystem.AddFile($"{baseDir}/docs/query-dsl/term-queries.md", new MockFileData("# Term Queries")); + fileSystem.AddFile($"{baseDir}/docs/query-dsl/full-text-queries.md", new MockFileData("# Full Text Queries")); + } + + public static TestDocumentationSetContext CreateAssemblerContext( + MockFileSystem fileSystem, + string repositoryPath, + ITestOutputHelper output, + TestDiagnosticsCollector? collector = null + ) + { + var context = CreateContext(fileSystem, repositoryPath, output, collector); + context.AssemblerBuild = true; + return context; + } + + public static TestDocumentationSetContext CreateContext( + MockFileSystem fileSystem, + string repositoryPath, + ITestOutputHelper output, + TestDiagnosticsCollector? collector = null + ) + { + var sourceDir = fileSystem.DirectoryInfo.New($"{repositoryPath}/docs"); + var outputDir = fileSystem.DirectoryInfo.New("/output"); + + // Try to find docset.yml or _docset.yml + var configPath = fileSystem.File.Exists($"{sourceDir.FullName}/docset.yml") + ? fileSystem.FileInfo.New($"{sourceDir.FullName}/docset.yml") + : fileSystem.FileInfo.New($"{sourceDir.FullName}/_docset.yml"); + + // Extract repository name from path (e.g., "/checkouts/current/platform" -> "platform") + var repositoryName = fileSystem.Path.GetFileName(repositoryPath); + + return new TestDocumentationSetContext(fileSystem, sourceDir, outputDir, configPath, output, repositoryName, collector); + } +} diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs new file mode 100644 index 000000000..ca2fa2795 --- /dev/null +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs @@ -0,0 +1,210 @@ +// 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 Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Navigation.Assembler; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Node; +using Elastic.Documentation.Navigation.Tests.Isolation; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Assembler; + +public class SiteNavigationTests(ITestOutputHelper output) +{ + private TestDocumentationSetContext CreateContext(MockFileSystem? fileSystem = null) + { + fileSystem ??= new MockFileSystem(); + var sourceDir = fileSystem.DirectoryInfo.New("/docs"); + var outputDir = fileSystem.DirectoryInfo.New("/output"); + var configPath = fileSystem.FileInfo.New("/docs/navigation.yml"); + + return new TestDocumentationSetContext(fileSystem, sourceDir, outputDir, configPath, output); + } + + [Fact] + public void ConstructorCreatesSiteNavigation() + { + // language=yaml + var yaml = """ + toc: + - toc: observability:// + path_prefix: /serverless/observability + - toc: serverless-search:// + path_prefix: /serverless/search + """; + + var siteNavFile = SiteNavigationFile.Deserialize(yaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create DocumentationSetNavigation instances for the referenced repos + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), fileSystem); + var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); + + var documentationSets = new List { observabilityNav, searchNav }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var navigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + navigation.Should().NotBeNull(); + navigation.Url.Should().Be("/"); + navigation.NavigationTitle.Should().Be("Elastic Docs"); + navigation.NavigationItems.Should().HaveCount(2); + } + + [Fact] + public void SiteNavigationWithNestedChildren() + { + // language=yaml + var yaml = """ + toc: + - toc: platform:// + path_prefix: /platform + children: + - toc: platform://deployment-guide + path_prefix: /platform/deployment + - toc: platform://cloud-guide + path_prefix: /platform/cloud + """; + + var siteNavFile = SiteNavigationFile.Deserialize(yaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + // Create DocumentationSetNavigation for platform + var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), fileSystem); + var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); + + var documentationSets = new List { platformNav }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); + var navigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix: null); + + navigation.NavigationItems.Should().HaveCount(1); + + var platform = navigation.NavigationItems.First(); + platform.Should().NotBeNull(); + } + + [Theory] + [InlineData(null, "/")] + [InlineData("", "/")] + [InlineData("docs", "/docs")] + [InlineData("/docs", "/docs")] + [InlineData("docs/", "/docs")] + [InlineData("/docs/", "/docs")] + [InlineData("api/docs", "/api/docs")] + [InlineData("/api/docs", "/api/docs")] + [InlineData("api/docs/", "/api/docs")] + [InlineData("/api/docs/", "/api/docs")] + public void SitePrefixNormalizesSlashes(string? sitePrefix, string expectedRootUrl) + { + // language=yaml + var yaml = """ + toc: + - toc: observability:// + path_prefix: observability + """; + + var siteNavFile = SiteNavigationFile.Deserialize(yaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + var documentationSets = new List { observabilityNav }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var navigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix); + + navigation.Should().NotBeNull(); + navigation.Url.Should().Be(expectedRootUrl, $"sitePrefix '{sitePrefix}' should normalize to '{expectedRootUrl}'"); + } + + [Theory] + [InlineData(null, "/observability")] + [InlineData("", "/observability")] + [InlineData("docs", "/docs/observability")] + [InlineData("/docs", "/docs/observability")] + [InlineData("docs/", "/docs/observability")] + [InlineData("/docs/", "/docs/observability")] + [InlineData("api/docs", "/api/docs/observability")] + [InlineData("/api/docs", "/api/docs/observability")] + [InlineData("api/docs/", "/api/docs/observability")] + [InlineData("/api/docs/", "/api/docs/observability")] + public void SitePrefixAppliedToNavigationItemUrls(string? sitePrefix, string expectedObservabilityUrl) + { + // language=yaml + var yaml = """ + toc: + - toc: observability:// + path_prefix: observability + """; + + var siteNavFile = SiteNavigationFile.Deserialize(yaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + var documentationSets = new List { observabilityNav }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var navigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix); + + navigation.NavigationItems.Should().HaveCount(1); + var observabilityItem = navigation.NavigationItems.First(); + observabilityItem.Url.Should().Be(expectedObservabilityUrl, $"sitePrefix '{sitePrefix}' should result in URL '{expectedObservabilityUrl}'"); + } + + [Theory] + [InlineData(null, "/observability", "/search")] + [InlineData("docs", "/docs/observability", "/docs/search")] + [InlineData("/docs", "/docs/observability", "/docs/search")] + [InlineData("docs/", "/docs/observability", "/docs/search")] + [InlineData("/docs/", "/docs/observability", "/docs/search")] + public void SitePrefixAppliedToMultipleNavigationItems(string? sitePrefix, string expectedObsUrl, string expectedSearchUrl) + { + // language=yaml + var yaml = """ + toc: + - toc: observability:// + path_prefix: observability + - toc: serverless-search:// + path_prefix: search + """; + + var siteNavFile = SiteNavigationFile.Deserialize(yaml); + var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); + + var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), fileSystem); + var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); + + var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), fileSystem); + var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); + + var documentationSets = new List { observabilityNav, searchNav }; + + var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); + var navigation = new SiteNavigation(siteNavFile, siteContext, documentationSets, sitePrefix); + + navigation.NavigationItems.Should().HaveCount(2); + + var observabilityItem = navigation.NavigationItems.First(); + observabilityItem.Url.Should().Be(expectedObsUrl, $"observability URL should be '{expectedObsUrl}' with sitePrefix '{sitePrefix}'"); + + var searchItem = navigation.NavigationItems.Skip(1).First(); + searchItem.Url.Should().Be(expectedSearchUrl, $"search URL should be '{expectedSearchUrl}' with sitePrefix '{sitePrefix}'"); + } +} diff --git a/tests/Navigation.Tests/Isolation/ConstructorTests.cs b/tests/Navigation.Tests/Isolation/ConstructorTests.cs new file mode 100644 index 000000000..2ea2f7de5 --- /dev/null +++ b/tests/Navigation.Tests/Isolation/ConstructorTests.cs @@ -0,0 +1,288 @@ +// 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 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; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class ConstructorTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void ConstructorInitializesRootProperties() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("/docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationRoot.Should().BeSameAs(navigation); + navigation.Parent.Should().BeNull(); + navigation.Hidden.Should().BeFalse(); + navigation.Id.Should().NotBeNullOrEmpty(); + navigation.NavigationTitle.Should().Be("index"); + navigation.IsUsingNavigationDropdown.Should().BeFalse(); + navigation.Url.Should().Be("/"); + } + + [Fact] + public void ConstructorSetsIsUsingNavigationDropdownFromFeatures() + { + // language=yaml + var yaml = """ + project: 'test-project' + features: + primary-nav: true + toc: + - file: index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("/docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.IsUsingNavigationDropdown.Should().BeTrue(); + } + + [Fact] + public void ConstructorCreatesFileNavigationLeafFromFileRef() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: getting-started.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("/docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(0); + var fileNav = navigation.Index.Should().BeOfType>().Subject; + fileNav.NavigationTitle.Should().Be("getting-started"); + fileNav.Url.Should().Be("/getting-started"); + fileNav.Hidden.Should().BeFalse(); + fileNav.NavigationRoot.Should().BeSameAs(navigation); + fileNav.Parent.Should().BeSameAs(navigation); // Top-level files have DocumentationSetNavigation as parent + } + + [Fact] + public void ConstructorCreatesHiddenFileNavigationLeaf() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - hidden: 404.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(0); + var fileNav = navigation.Index.Should().BeOfType>().Subject; + fileNav.Hidden.Should().BeTrue(); + fileNav.Url.Should().Be("/404"); + } + + [Fact] + public void ConstructorCreatesCrossLinkNavigation() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - title: "External Guide" + crosslink: docs-content://guide.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + + navigation.NavigationItems.Should().HaveCount(0); + var crossLink = navigation.Index.Should().BeOfType().Subject; + crossLink.NavigationTitle.Should().Be("External Guide"); + crossLink.Url.Should().Be("https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main"); + } + + [Fact] + public void ConstructorCreatesFolderNavigationWithChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: setup + children: + - file: index.md + - file: install.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var folder = navigation.NavigationItems.First().Should().BeOfType>().Subject; + folder.Url.Should().Be("/setup"); + folder.NavigationItems.Should().HaveCount(1); + + var firstFile = folder.Index.Should().BeOfType>().Subject; + firstFile.Url.Should().Be("/setup"); // index.md becomes /setup + firstFile.Parent.Should().BeSameAs(folder); + + var secondFile = folder.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + secondFile.Url.Should().Be("/setup/install"); + } + + [Fact] + public void ConstructorCreatesTableOfContentsNavigationWithChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - toc: api + """; + + // language=yaml + var tocYaml = """ + toc: + - file: index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + fileSystem.AddDirectory("/docs/api"); + fileSystem.AddFile("/docs/api/toc.yml", new MockFileData(tocYaml)); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var toc = navigation.NavigationItems.First().Should().BeOfType>().Subject; + toc.Url.Should().Be("/api"); + toc.NavigationItems.Should().HaveCount(0); + + var file = toc.Index.Should().BeOfType>().Subject; + file.Url.Should().Be("/api"); // index.md becomes /api + file.Parent.Should().BeSameAs(toc); + file.NavigationRoot.Should().BeSameAs(navigation); + } + + [Fact] + public void ConstructorReadsTableOfContentsFromTocYmlFile() + { + // language=yaml + var docSetYaml = """ + project: 'test-project' + toc: + - toc: api + """; + + // language=yaml + var tocYaml = """ + toc: + - file: overview.md + - file: reference.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/api"); + fileSystem.AddFile("/docs/api/toc.yml", new MockFileData(tocYaml)); + fileSystem.AddDirectory("/docs"); + + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, docSetYaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var toc = navigation.NavigationItems.First().Should().BeOfType>().Subject; + toc.NavigationItems.Should().HaveCount(1); + + var overview = toc.Index.Should().BeOfType>().Subject; + overview.Url.Should().Be("/api/overview"); + + var reference = toc.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + reference.Url.Should().Be("/api/reference"); + } + + [Fact] + public async Task ConstructorProcessesTocYmlItemsBeforeChildrenFromNavigation() + { + // language=yaml + var docSetYaml = """ + project: 'test-project' + toc: + - toc: api + children: + - toc: extra + """; + + // language=yaml + var tocYaml = """ + toc: + - file: from-toc.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/api"); + fileSystem.AddDirectory("/docs/api/extra"); + fileSystem.AddFile("/docs/api/toc.yml", new MockFileData(tocYaml)); + fileSystem.AddDirectory("/docs"); + + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, docSetYaml, fileSystem.NewDirInfo("docs")); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(0); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + var diagnostics = context.Diagnostics; + // We expect 2 errors: one for the TOC validation error, and one from navigation constructor + // After LoadAndResolve removes the invalid TOC item, the navigation sees an empty TOC + context.Diagnostics.Should().HaveCount(2); + diagnostics.Should().Contain(d => + d.Message.Contains("TableOfContents 'api' may not contain children, define children in 'api/toc.yml' instead.")); + diagnostics.Should().Contain(d => + d.Message.Contains("has no table of contents defined")); + } +} diff --git a/tests/Navigation.Tests/Isolation/DocumentationSetNavigationTestBase.cs b/tests/Navigation.Tests/Isolation/DocumentationSetNavigationTestBase.cs new file mode 100644 index 000000000..b81d584eb --- /dev/null +++ b/tests/Navigation.Tests/Isolation/DocumentationSetNavigationTestBase.cs @@ -0,0 +1,20 @@ +// 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; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public abstract class DocumentationSetNavigationTestBase(ITestOutputHelper output) +{ + protected TestDocumentationSetContext CreateContext(MockFileSystem? fileSystem = null) + { + fileSystem ??= new MockFileSystem(); + var sourceDir = fileSystem.DirectoryInfo.New("/docs"); + var outputDir = fileSystem.DirectoryInfo.New("/output"); + var configPath = fileSystem.FileInfo.New("/docs/docset.yml"); + + return new TestDocumentationSetContext(fileSystem, sourceDir, outputDir, configPath, output, "docs-builder"); + } +} diff --git a/tests/Navigation.Tests/Isolation/DynamicUrlTests.cs b/tests/Navigation.Tests/Isolation/DynamicUrlTests.cs new file mode 100644 index 000000000..1c3244d9e --- /dev/null +++ b/tests/Navigation.Tests/Isolation/DynamicUrlTests.cs @@ -0,0 +1,290 @@ +// 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 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; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class DynamicUrlTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void DynamicUrlUpdatesWhenRootUrlChanges() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: setup + children: + - file: install.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + folder.Should().NotBeNull(); + var file = folder.Index; + + // Initial URL + file.Url.Should().Be("/setup/install"); + + // Change root URL + navigation.HomeProvider = new NavigationHomeProvider("/v8.0", navigation.NavigationRoot); + + // URLs should update dynamically + // Since folder has no index child, its URL is the first child's URL + folder.Url.Should().Be("/v8.0/setup/install"); + file.Url.Should().Be("/v8.0/setup/install"); + + // Change root URL + navigation.HomeProvider = new NavigationHomeProvider("/v9.0", navigation.NavigationRoot); + + // URLs should update dynamically + // Since folder has no index child, its URL is the first child's URL + folder.Url.Should().Be("/v9.0/setup/install"); + file.Url.Should().Be("/v9.0/setup/install"); + } + + [Fact] + public void UrlRootPropagatesCorrectlyThroughFolders() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: outer + children: + - folder: inner + children: + - file: deep.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var outerFolder = navigation.NavigationItems.First() as FolderNavigation; + var innerFolder = outerFolder!.NavigationItems.First() as FolderNavigation; + var file = innerFolder!.Index; + + file.Url.Should().Be("/outer/inner/deep"); + + // Change root URL + navigation.HomeProvider = new NavigationHomeProvider("/base", navigation.NavigationRoot); + + file.Url.Should().Be("/base/outer/inner/deep"); + } + + [Fact] + public void FolderWithoutIndexUsesFirstChildUrl() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - file: getting-started.md + - file: advanced.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + + // Folder has no index.md, so URL should be the first child's URL + folder!.Url.Should().Be("/guides/getting-started"); + } + + [Fact] + public void FolderWithNestedChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - file: getting-started.md + children: + - file: advanced.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + + // Folder has no index.md, so URL should be the first child's URL + folder!.Url.Should().Be("/guides/getting-started"); + + var gettingStarted = folder.NavigationItems.First() as VirtualFileNavigation; + gettingStarted.Should().NotBeNull(); + gettingStarted.Url.Should().Be("/guides/getting-started"); + var advanced = gettingStarted.NavigationItems.First() as FileNavigationLeaf; + advanced.Should().NotBeNull(); + advanced.Url.Should().Be("/guides/advanced"); + + advanced.Parent.Should().BeSameAs(gettingStarted); + gettingStarted.Parent.Should().BeSameAs(folder); + } + + [Fact] + public void FolderWithNestedDeeplinkedChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - file: clients/getting-started.md + children: + - file: advanced.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + + // Folder has no index.md, so URL should be the first child's URL + folder!.Url.Should().Be("/guides/clients/getting-started"); + + var gettingStarted = folder.NavigationItems.First() as VirtualFileNavigation; + gettingStarted.Should().NotBeNull(); + gettingStarted.Url.Should().Be("/guides/clients/getting-started"); + var advanced = gettingStarted.NavigationItems.First() as FileNavigationLeaf; + advanced.Should().NotBeNull(); + advanced.Url.Should().Be("/guides/advanced"); + + advanced.Parent.Should().BeSameAs(gettingStarted); + gettingStarted.Parent.Should().BeSameAs(folder); + } + + [Fact] + public void FolderWithNestedDeeplinkedOfIndexChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - file: clients/index.md + children: + - file: advanced.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + + // Folder has no index.md, so URL should be the first child's URL + folder!.Url.Should().Be("/guides/clients"); + + var gettingStarted = folder.NavigationItems.First() as VirtualFileNavigation; + gettingStarted.Should().NotBeNull(); + gettingStarted.Url.Should().Be("/guides/clients"); + var advanced = gettingStarted.NavigationItems.First() as FileNavigationLeaf; + advanced.Should().NotBeNull(); + advanced.Url.Should().Be("/guides/advanced"); + + advanced.Parent.Should().BeSameAs(gettingStarted); + gettingStarted.Parent.Should().BeSameAs(folder); + } + + [Fact] + public void FolderWithIndexUsesOwnUrl() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - file: index.md + - file: advanced.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + + // Folder has index.md, so URL should be the folder path + folder!.Url.Should().Be("/guides"); + } + + [Fact] + public void UrlRootChangesForTableOfContentsNavigation() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: guides + children: + - toc: api + """; + + // language=yaml + var tocYaml = """ + toc: + - file: reference.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/guides/api"); + fileSystem.AddFile("/docs/guides/api/toc.yml", new MockFileData(tocYaml)); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var folder = navigation.NavigationItems.First() as FolderNavigation; + var toc = folder!.NavigationItems.First() as TableOfContentsNavigation; + var file = toc!.Index; + + // The TOC becomes the new URL root, so the file URL is based on TOC's URL + toc.Url.Should().Be("/guides/api/reference"); + file.Url.Should().Be("/guides/api/reference"); + + // Change root URL + navigation.HomeProvider = new NavigationHomeProvider("/v2", navigation.NavigationRoot); + + // Both TOC and file URLs should update + navigation.Url.Should().Be("/v2/guides/api/reference"); + toc.Url.Should().Be("/v2/guides/api/reference"); + file.Url.Should().Be("/v2/guides/api/reference"); + } +} diff --git a/tests/Navigation.Tests/Isolation/FileInfoValidationTests.cs b/tests/Navigation.Tests/Isolation/FileInfoValidationTests.cs new file mode 100644 index 000000000..1ecff9743 --- /dev/null +++ b/tests/Navigation.Tests/Isolation/FileInfoValidationTests.cs @@ -0,0 +1,477 @@ +// 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 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; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +/// +/// Tests that validate all FileInfo properties on FileNavigationItems resolve to files that actually exist. +/// These tests ensure that the navigation system correctly creates file references for all scenarios. +/// +public class FileInfoValidationTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void AllFileNavigationItemsHaveValidFileInfoForSimpleFiles() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: getting-started.md + - file: installation.md + - file: configuration.md + """; + + var fileSystem = CreateMockFileSystemWithFiles([ + "/docs/getting-started.md", + "/docs/installation.md", + "/docs/configuration.md" + ]); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + // Validate all file navigation items have valid FileInfo + var fileNavigationItems = GetAllFileNavigationItems(navigation); + fileNavigationItems.Should().HaveCount(3); + + foreach (var fileNav in fileNavigationItems) + { + fileNav.FileInfo.Should().NotBeNull($"FileInfo for {fileNav.Url} should not be null"); + fileNav.FileInfo.Exists.Should().BeTrue($"File at {fileNav.FileInfo.FullName} should exist"); + } + + // Validate no errors or warnings were emitted + context.Diagnostics.Should().BeEmpty("navigation construction should not emit any diagnostics"); + } + + [Fact] + public void AllFileNavigationItemsHaveValidFileInfoForVirtualFiles() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: section1.md + - file: section2.md + - file: advanced.md + children: + - file: topics.md + - file: patterns.md + """; + + var fileSystem = CreateMockFileSystemWithFiles([ + "/docs/guide.md", + "/docs/section1.md", + "/docs/section2.md", + "/docs/advanced.md", + "/docs/topics.md", + "/docs/patterns.md" + ]); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + // Validate all file navigation items (including virtual files and their children) + var fileNavigationItems = GetAllFileNavigationItems(navigation); + fileNavigationItems.Should().HaveCount(6, "should include 2 virtual files and 4 child files"); + + foreach (var fileNav in fileNavigationItems) + { + fileNav.FileInfo.Should().NotBeNull($"FileInfo for {fileNav.Url} should not be null"); + fileNav.FileInfo.Exists.Should().BeTrue($"File at {fileNav.FileInfo.FullName} should exist"); + } + + // Validate no errors or warnings were emitted + context.Diagnostics.Should().BeEmpty("navigation construction should not emit any diagnostics"); + } + + [Fact] + public void AllFileNavigationItemsHaveValidFileInfoForFoldersWithFiles() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: setup + children: + - file: index.md + - file: installation.md + - file: configuration.md + - folder: advanced + children: + - file: index.md + - file: topics.md + """; + + var fileSystem = CreateMockFileSystemWithFiles([ + "/docs/setup/index.md", + "/docs/setup/installation.md", + "/docs/setup/configuration.md", + "/docs/advanced/index.md", + "/docs/advanced/topics.md" + ]); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + // Validate all file navigation items within folders + var fileNavigationItems = GetAllFileNavigationItems(navigation); + fileNavigationItems.Should().HaveCount(5, "should include all files within folders"); + + foreach (var fileNav in fileNavigationItems) + { + fileNav.FileInfo.Should().NotBeNull($"FileInfo for {fileNav.Url} should not be null"); + fileNav.FileInfo.Exists.Should().BeTrue($"File at {fileNav.FileInfo.FullName} should exist"); + } + + // Validate no errors or warnings were emitted + context.Diagnostics.Should().BeEmpty("navigation construction should not emit any diagnostics"); + } + + /// + /// Tests that files within folders inside nested TOCs have their FileInfo paths resolved correctly. + /// This validates that the full path (including folder components) is used for file resolution. + /// + [Fact] + public void AllFileNavigationItemsHaveValidFileInfoForDeeplyNestedTocFiles() + { + // language=yaml + var docsetYaml = """ + project: 'test-project' + toc: + - file: index.md + - toc: development + - folder: guides + children: + - file: getting-started.md + - toc: advanced + """; + + // Create a TOC file in a subdirectory + // language=yaml + var developmentTocYaml = """ + toc: + - file: index.md + - file: contributing.md + - folder: internals + children: + - file: architecture.md + """; + + // Create a deeply nested TOC file + // language=yaml + var advancedTocYaml = """ + toc: + - file: index.md + - file: patterns.md + - toc: performance + """; + + // Create a third-level nested TOC + // language=yaml + var performanceTocYaml = """ + toc: + - file: index.md + - file: optimization.md + """; + + var fileSystem = CreateMockFileSystemWithFiles([ + "/docs/index.md", + "/docs/development/toc.yml", + "/docs/development/index.md", + "/docs/development/contributing.md", + "/docs/development/internals/index.md", + "/docs/development/internals/architecture.md", + "/docs/guides/getting-started.md", + "/docs/guides/advanced/toc.yml", + "/docs/guides/advanced/index.md", + "/docs/guides/advanced/patterns.md", + "/docs/guides/advanced/performance/toc.yml", + "/docs/guides/advanced/performance/index.md", + "/docs/guides/advanced/performance/optimization.md" + ]); + + // Add TOC file contents + fileSystem.File.WriteAllText("/docs/development/toc.yml", developmentTocYaml); + fileSystem.File.WriteAllText("/docs/guides/advanced/toc.yml", advancedTocYaml); + fileSystem.File.WriteAllText("/docs/guides/advanced/performance/toc.yml", performanceTocYaml); + + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, docsetYaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + // Validate all file navigation items across all nested TOCs + var fileNavigationItems = GetAllFileNavigationItems(navigation); + fileNavigationItems.Count.Should().Be(9, "should include all files from main TOC and nested TOCs"); + + foreach (var fileNav in fileNavigationItems) + { + fileNav.FileInfo.Should().NotBeNull($"FileInfo for {fileNav.Url} should not be null"); + fileNav.FileInfo.Exists.Should().BeTrue($"File at {fileNav.FileInfo.FullName} should exist for {fileNav.Url}"); + } + + // Validate no errors or warnings were emitted + context.Diagnostics.Should().BeEmpty("navigation construction should not emit any diagnostics"); + } + + /// + /// Tests that child files of virtual files in nested TOCs have their FileInfo paths resolved correctly. + /// This validates that parent virtual file directory components are included in file resolution. + /// + [Fact] + public void AllFileNavigationItemsHaveValidFileInfoForComplexMixedStructure() + { + // language=yaml + var docsetYaml = """ + project: 'test-project' + toc: + - file: index.md + - file: quick-start.md + children: + - file: step1.md + - file: step2.md + - folder: setup + children: + - file: installation.md + - file: configuration.md + - toc: advanced + - toc: reference + - file: faq.md + children: + - file: general.md + - folder: troubleshooting + children: + - file: common-issues.md + """; + + // Setup/advanced TOC + // language=yaml + var setupAdvancedTocYaml = """ + toc: + - file: index.md + - file: custom-config.md + """; + + // Reference TOC + // language=yaml + var referenceTocYaml = """ + toc: + - file: index.md + - file: api.md + children: + - file: endpoints.md + - folder: cli + children: + - file: commands.md + """; + + var fileSystem = CreateMockFileSystemWithFiles([ + "/docs/index.md", + "/docs/quick-start.md", + "/docs/step1.md", + "/docs/step2.md", + "/docs/setup/installation.md", + "/docs/setup/configuration.md", + "/docs/setup/advanced/toc.yml", + "/docs/setup/advanced/index.md", + "/docs/setup/advanced/custom-config.md", + "/docs/reference/toc.yml", + "/docs/reference/index.md", + "/docs/reference/api.md", + "/docs/reference/endpoints.md", + "/docs/reference/cli/index.md", + "/docs/reference/cli/commands.md", + "/docs/faq.md", + "/docs/general.md", + "/docs/troubleshooting/index.md", + "/docs/troubleshooting/common-issues.md" + ]); + + // Add TOC file contents + fileSystem.File.WriteAllText("/docs/setup/advanced/toc.yml", setupAdvancedTocYaml); + fileSystem.File.WriteAllText("/docs/reference/toc.yml", referenceTocYaml); + + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, docsetYaml, fileSystem.NewDirInfo("docs")); + + var setup = docSet.TableOfContents.OfType().FirstOrDefault(f => f.PathRelativeToDocumentationSet == "setup"); + setup.Should().NotBeNull("setup folder should exist"); + setup.Children.Count.Should().Be(3, "should include 2 children"); + var advanced = setup.Children.OfType().FirstOrDefault(); + advanced.Should().NotBeNull("advanced TOC should exist"); + advanced.PathRelativeToDocumentationSet.Should().Be("setup/advanced"); + + var fileRefs = docSet.TableOfContents.SelectMany(DocumentationSetFile.GetFileRefs).ToList(); + foreach (var fileRef in fileRefs) + { + var path = fileSystem.FileInfo.New(Path.Combine(context.DocumentationSourceDirectory.FullName, fileRef.PathRelativeToDocumentationSet)); + path.Exists.Should().BeTrue($"Expected file {path.FullName} to exist"); + } + + fileRefs.Count.Should().Be(fileRefs.Distinct().Count(), "should not have duplicate file references"); + + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + // Validate all file navigation items in this complex structure + var fileNavigationItems = GetAllFileNavigationItems(navigation); + fileNavigationItems.Count.Should().Be(15, "should include all files from all structures"); + + foreach (var fileNav in fileNavigationItems) + { + fileNav.FileInfo.Should().NotBeNull($"FileInfo for {fileNav.Url} should not be null"); + fileNav.FileInfo.Exists.Should().BeTrue($"File at {fileNav.FileInfo.FullName} should exist for {fileNav.Url}"); + } + + // Validate no errors or warnings were emitted + context.Diagnostics.Should().BeEmpty("navigation construction should not emit any diagnostics"); + } + + [Fact] + public void AllFileNavigationItemsHaveValidFileInfoForNestedFolders() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: docs + children: + - file: index.md + - folder: guides + children: + - file: basics.md + - folder: advanced + children: + - file: expert.md + """; + + var fileSystem = CreateMockFileSystemWithFiles([ + "/docs/docs/index.md", + "/docs/docs/guides/basics.md", + "/docs/docs/guides/advanced/expert.md" + ]); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + // Validate all file navigation items in nested folders + var fileNavigationItems = GetAllFileNavigationItems(navigation); + fileNavigationItems.Should().HaveCount(3); + + foreach (var fileNav in fileNavigationItems) + { + fileNav.FileInfo.Should().NotBeNull($"FileInfo for {fileNav.Url} should not be null"); + fileNav.FileInfo.Exists.Should().BeTrue($"File at {fileNav.FileInfo.FullName} should exist"); + } + + // Validate no errors or warnings were emitted + context.Diagnostics.Should().BeEmpty("navigation construction should not emit any diagnostics"); + } + + [Fact] + public void AllFileNavigationItemsHaveValidFileInfoForVirtualFilesWithNestedChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: chapter1.md + children: + - file: section1.md + children: + - file: subsection1.md + """; + + var fileSystem = CreateMockFileSystemWithFiles([ + "/docs/guide.md", + "/docs/chapter1.md", + "/docs/section1.md", + "/docs/subsection1.md" + ]); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + // Validate all file navigation items in deeply nested virtual file structure + var fileNavigationItems = GetAllFileNavigationItems(navigation); + fileNavigationItems.Should().HaveCount(4); + + foreach (var fileNav in fileNavigationItems) + { + fileNav.FileInfo.Should().NotBeNull($"FileInfo for {fileNav.Url} should not be null"); + fileNav.FileInfo.Exists.Should().BeTrue($"File at {fileNav.FileInfo.FullName} should exist for URL {fileNav.Url}"); + } + + // Validate no errors or warnings were emitted + context.Diagnostics.Should().BeEmpty("navigation construction should not emit any diagnostics"); + } + + /// + /// Helper method to create a MockFileSystem with the specified files. + /// Creates all parent directories automatically. + /// + private static MockFileSystem CreateMockFileSystemWithFiles(string[] filePaths) + { + var fileSystem = new MockFileSystem(); + + foreach (var filePath in filePaths) + { + // Ensure directory exists + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory)) + fileSystem.Directory.CreateDirectory(directory); + + // Create file with simple content (can be overridden later) + var fileName = Path.GetFileName(filePath); + var title = fileName.Replace(".md", "").Replace(".yml", ""); + fileSystem.File.WriteAllText(filePath, $"# {title}\n\nContent for {fileName}"); + } + + return fileSystem; + } + + /// + /// Recursively collects all FileNavigationLeaf instances from the navigation tree. + /// For VirtualFileNavigation items, extracts the Index (which is a FileNavigationLeaf). + /// + private static HashSet> GetAllFileNavigationItems(INodeNavigationItem node) + { + var result = new HashSet>(); + if (node.Index is FileNavigationLeaf index) + result.Add(index); + + foreach (var item in node.NavigationItems) + { + // Collect direct file navigation leafs + if (item is FileNavigationLeaf fileLeaf) + result.Add(fileLeaf); + // Recursively process children + else if (item is INodeNavigationItem childNode) + { + foreach (var f in GetAllFileNavigationItems(childNode)) + result.Add(f); + } + } + + return result; + } +} diff --git a/tests/Navigation.Tests/Isolation/FileNavigationTests.cs b/tests/Navigation.Tests/Isolation/FileNavigationTests.cs new file mode 100644 index 000000000..fb679d10d --- /dev/null +++ b/tests/Navigation.Tests/Isolation/FileNavigationTests.cs @@ -0,0 +1,211 @@ +// 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 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; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class FileNavigationTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void FileWithNoChildrenCreatesFileNavigationLeaf() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: getting-started.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(0); + var fileNav = navigation.Index.Should().BeOfType>().Subject; + fileNav.Url.Should().Be("/getting-started"); + } + + [Fact] + public void FileWithChildrenCreatesFileNavigation() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: section1.md + - file: section2.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var fileNav = navigation.NavigationItems.First().Should().BeOfType>().Subject; + fileNav.Url.Should().Be("/guide"); + fileNav.NavigationItems.Should().HaveCount(2); + + var section1 = fileNav.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + section1.Url.Should().Be("/section1"); + section1.Parent.Should().BeSameAs(fileNav); + + var section2 = fileNav.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + section2.Url.Should().Be("/section2"); + section2.Parent.Should().BeSameAs(fileNav); + } + + [Fact] + public void FileWithChildrenDeeplinksPreservesPaths() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: nest/guide.md + children: + - file: section1.md + - file: section2.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var fileNav = navigation.NavigationItems.First().Should().BeOfType>().Subject; + fileNav.Url.Should().Be("/nest/guide"); + fileNav.NavigationItems.Should().HaveCount(2); + + var section1 = fileNav.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + section1.Url.Should().Be("/nest/section1"); + section1.Parent.Should().BeSameAs(fileNav); + + var section2 = fileNav.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + section2.Url.Should().Be("/nest/section2"); + section2.Parent.Should().BeSameAs(fileNav); + } + + [Fact] + public void FileWithNestedChildrenBuildsCorrectly() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: chapter1.md + children: + - file: subsection.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + navigation.NavigationItems.Should().HaveCount(1); + var guideFile = navigation.NavigationItems.First().Should().BeOfType>().Subject; + guideFile.Url.Should().Be("/guide"); + guideFile.NavigationItems.Should().HaveCount(1); + + var chapter1 = guideFile.NavigationItems.First().Should().BeOfType>().Subject; + chapter1.Url.Should().Be("/chapter1"); + chapter1.Parent.Should().BeSameAs(guideFile); + chapter1.NavigationItems.Should().HaveCount(1); + + var subsection = chapter1.NavigationItems.First().Should().BeOfType>().Subject; + subsection.Url.Should().Be("/subsection"); + subsection.Parent.Should().BeSameAs(chapter1); + } + + [Fact] + public void FileNavigationUrlUpdatesWhenRootChanges() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: section1.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + var fileNav = navigation.NavigationItems.First() as INodeNavigationItem; + var child = fileNav!.NavigationItems.First(); + + // Initial URLs + fileNav.Url.Should().Be("/guide"); + child.Url.Should().Be("/section1"); + + // Change root URL + navigation.HomeProvider = new NavigationHomeProvider("/v2", navigation.NavigationRoot); + + // URLs should update dynamically + fileNav.Url.Should().Be("/v2/guide"); + child.Url.Should().Be("/v2/section1"); + } + + [Fact] + public void FileNavigationMixedWithFolderChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: intro.md + - folder: advanced + children: + - file: topics.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + var guideFile = navigation.NavigationItems.First().Should().BeOfType>().Subject; + guideFile.NavigationItems.Should().HaveCount(2); + + var intro = guideFile.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + intro.Url.Should().Be("/intro"); + + var advancedFolder = guideFile.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + advancedFolder.Url.Should().Be("/advanced/topics"); // No index, uses first child + advancedFolder.NavigationItems.Should().HaveCount(0); + + var topics = advancedFolder.Index.Should().BeOfType>().Subject; + topics.Url.Should().Be("/advanced/topics"); + } +} diff --git a/tests/Navigation.Tests/Isolation/FolderIndexFileRefTests.cs b/tests/Navigation.Tests/Isolation/FolderIndexFileRefTests.cs new file mode 100644 index 000000000..32cd99a0e --- /dev/null +++ b/tests/Navigation.Tests/Isolation/FolderIndexFileRefTests.cs @@ -0,0 +1,235 @@ +// 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 Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Node; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class FolderIndexFileRefTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public async Task FolderWithFileCreatesCorrectStructure() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: getting-started + file: getting-started.md + children: + - file: install.md + - file: configure.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Should create a FolderNavigation with the file as index + navigation.NavigationItems.Should().HaveCount(1); + var folder = navigation.NavigationItems.First().Should().BeOfType>().Subject; + + // Children should be scoped to the folder + folder.Url.Should().Be("/getting-started/getting-started"); + folder.NavigationItems.Should().HaveCount(2); // install.md, configure.md + + // Verify no errors + context.Collector.Errors.Should().Be(0); + } + + [Fact] + public async Task FolderWithFileChildrenPathsAreScopedToFolder() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: getting-started + file: getting-started.md + children: + - file: install.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Verify that the FileRef for getting-started.md is a FolderIndexFileRef + var folderItem = docSet.TableOfContents.First().Should().BeOfType().Subject; + folderItem.Children.Should().HaveCount(2); // getting-started.md and install.md + + var indexFile = folderItem.Children.First().Should().BeOfType().Subject; + indexFile.PathRelativeToDocumentationSet.Should().Be("getting-started/getting-started.md"); + + var childFile = folderItem.Children.ElementAt(1).Should().BeOfType().Subject; + childFile.PathRelativeToDocumentationSet.Should().Be("getting-started/install.md"); + } + + [Fact] + public async Task FolderWithFileEmitsHintWhenFileNameDoesNotMatchFolder() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: getting-started + file: intro.md + children: + - file: install.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Should emit hint about file name not matching folder name + context.Collector.Hints.Should().BeGreaterThan(0); + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Severity == Severity.Hint && + d.Message.Contains("intro.md") && + d.Message.Contains("getting-started") && + d.Message.Contains("Best practice")); + } + + [Fact] + public async Task FolderWithFileDoesNotEmitHintWhenFileNameMatchesFolder() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: getting-started + file: getting-started.md + children: + - file: install.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Should not emit any hints + context.Collector.Hints.Should().Be(0); + context.Diagnostics.Should().BeEmpty(); + } + + [Fact] + public async Task FolderWithFileEmitsErrorForDeepLinkingInFile() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: getting-started + file: intro/file.md + children: + - file: install.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Should emit error about deep linking in the file attribute + context.Collector.Errors.Should().BeGreaterThan(0); + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Severity == Severity.Error && + d.Message.Contains("Deep linking on folder 'file' is not supported") && + d.Message.Contains("intro/file.md")); + } + + [Fact] + public async Task FolderWithIndexMdFileDoesNotNeedToMatchFolderName() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: getting-started + file: index.md + children: + - file: install.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // index.md is a special case - should not emit hint + // (Though the hint check doesn't exclude index.md, it's a reasonable best practice to allow it) + context.Collector.Errors.Should().Be(0); + } + + [Fact] + public async Task FolderWithFileCaseInsensitiveMatch() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: GettingStarted + file: getting-started.md + children: + - file: install.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Case-insensitive match should not emit hint + context.Collector.Hints.Should().Be(0); + context.Diagnostics.Should().BeEmpty(); + } +} diff --git a/tests/Navigation.Tests/Isolation/NavigationStructureTests.cs b/tests/Navigation.Tests/Isolation/NavigationStructureTests.cs new file mode 100644 index 000000000..2d0c68488 --- /dev/null +++ b/tests/Navigation.Tests/Isolation/NavigationStructureTests.cs @@ -0,0 +1,365 @@ +// 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 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; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class NavigationStructureTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public void NavigationIndexIsSetCorrectly() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: first.md + - file: second.md + - file: third.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + navigation.NavigationIndex.Should().Be(0); + navigation.Index.NavigationIndex.Should().Be(0); + navigation.NavigationItems.ElementAt(0).NavigationIndex.Should().Be(1); + navigation.NavigationItems.ElementAt(1).NavigationIndex.Should().Be(2); + } + + [Fact] + public void CanQueryNavigationForBothInterfaceAndConcreteTypes() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: first.md + - folder: guides + children: + - file: second.md + - file: third.md + - file: fourth.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + // Create navigation using the covariant factory interface + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + // Query for all leaf items using the base interface type + var allLeafItems = navigation.NavigationItems.Concat([navigation.Index]) + .SelectMany(item => item is INodeNavigationItem node + ? node.NavigationItems.OfType>().Concat([node.Index]) + : item is ILeafNavigationItem leaf + ? [leaf] + : []) + .ToList(); + + // All items are queryable as ILeafNavigationItem due to covariance + allLeafItems.Should().HaveCount(4); + allLeafItems.Should().AllBeAssignableTo>(); + allLeafItems.Should().AllBeAssignableTo>(); + allLeafItems.Select(l => l.NavigationTitle).Should().BeEquivalentTo(["first", "second", "third", "fourth"]); + + // The navigation items themselves are FileNavigationLeaf at runtime + allLeafItems.Should().AllBeOfType>(); + + // And the Model property on each leaf contains TestDocumentationFile instances + var allModels = allLeafItems.Select(l => l.Model).ToList(); + allModels.Should().AllBeOfType(); + + // Access the underlying model through the interface + foreach (var leaf in allLeafItems) + { + // The Model property returns IDocumentationFile due to covariance + leaf.Model.Should().BeAssignableTo(); + leaf.Model.NavigationTitle.Should().NotBeNullOrEmpty(); + + // But at runtime, it's still TestDocumentationFile + leaf.Model.Should().BeOfType(); + + // Can access concrete type through pattern matching without explicit cast + if (leaf.Model is TestDocumentationFile concreteFile) + concreteFile.NavigationTitle.Should().Be(leaf.NavigationTitle); + } + + // Demonstrate type-safe LINQ queries work with the interface type + var firstItem = allLeafItems.FirstOrDefault(l => l.Model.NavigationTitle == "first"); + firstItem.Should().NotBeNull(); + firstItem.Url.Should().Be("/first"); + } + + [Fact] + public async Task ComplexNestedStructureBuildsCorrectly() + { + // language=yaml + var yaml = """ + project: 'docs-builder' + features: + primary-nav: true + toc: + - file: index.md + - folder: setup + children: + - file: index.md + - toc: advanced + - title: "External" + crosslink: other://link.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/setup/advanced"); + fileSystem.AddDirectory("/docs/setup/advanced/performance"); + fileSystem.AddFile("/docs/setup/advanced/toc.yml", new MockFileData( + // language=yaml + """ + toc: + - file: index.md + - toc: performance + """)); + + // language=yaml + var performanceTocYaml = """ + toc: + - file: index.md + - file: tuning.md + - file: benchmarks.md + """; + fileSystem.AddFile("/docs/setup/advanced/performance/toc.yml", new MockFileData(performanceTocYaml)); + // Add index.md files that should be automatically discovered as placeholders + fileSystem.AddFile("/docs/setup/advanced/index.md", new MockFileData("# Advanced")); + fileSystem.AddFile("/docs/setup/advanced/performance/index.md", new MockFileData("# Performance")); + fileSystem.AddFile("/docs/setup/advanced/performance/tuning.md", new MockFileData("# Tuning")); + fileSystem.AddFile("/docs/setup/advanced/performance/benchmarks.md", new MockFileData("# Benchmarks")); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + navigation.NavigationItems.Should().HaveCount(2); + navigation.IsUsingNavigationDropdown.Should().BeTrue(); + + // First item: simple file + var indexFile = navigation.Index.Should().BeOfType>().Subject; + indexFile.Url.Should().Be("/"); // index.md becomes / + + // Second item: complex nested structure + var setupFolder = navigation.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + setupFolder.NavigationItems.Should().HaveCount(1); + setupFolder.Url.Should().Be("/setup"); + + var setupIndex = setupFolder.Index.Should().BeOfType>().Subject; + setupIndex.Url.Should().Be("/setup"); // index.md becomes /setup + + var advancedToc = setupFolder.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + advancedToc.Url.Should().Be("/setup/advanced"); + // Advanced TOC has index.md and the nested performance TOC as children + advancedToc.NavigationItems.Should().HaveCount(1); + + var advancedIndex = advancedToc.Index.Should().BeOfType>().Subject; + advancedIndex.Url.Should().Be("/setup/advanced"); + + var performanceToc = advancedToc.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + performanceToc.Url.Should().Be("/setup/advanced/performance"); + performanceToc.NavigationItems.Should().HaveCount(2); + + var performanceIndex = performanceToc.Index.Should().BeOfType>().Subject; + performanceIndex.Url.Should().Be("/setup/advanced/performance"); + + var tuning = performanceToc.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + tuning.Url.Should().Be("/setup/advanced/performance/tuning"); + + var benchmarks = performanceToc.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + benchmarks.Url.Should().Be("/setup/advanced/performance/benchmarks"); + + // Third item: crosslink + _ = navigation.NavigationItems.ElementAt(1).Should().BeOfType().Subject; + + // Verify no errors were emitted + context.Diagnostics.Should().BeEmpty(); + } + + [Fact] + public void NestedTocUrlsDoNotDuplicatePath() + { + // This test verifies that nested TOC URLs are constructed correctly + // without duplicating path segments (e.g., /setup/advanced not /setup/setup/advanced) + + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: setup + children: + - file: index.md + - toc: advanced + """; + + // language=yaml + var advancedTocYaml = """ + toc: + - file: index.md + - toc: performance + """; + + // language=yaml + var performanceTocYaml = """ + toc: + - file: index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddFile("/docs/setup/index.md", new MockFileData("# Setup")); + fileSystem.AddFile("/docs/setup/advanced/index.md", new MockFileData("# Advanced Setup")); + fileSystem.AddFile("/docs/setup/advanced/toc.yml", new MockFileData(advancedTocYaml)); + fileSystem.AddFile("/docs/setup/advanced/performance/index.md", new MockFileData("# Performance")); + fileSystem.AddFile("/docs/setup/advanced/performance/toc.yml", new MockFileData(performanceTocYaml)); + + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + var setupFolder = navigation.NavigationItems.First().Should().BeOfType>().Subject; + setupFolder.Url.Should().Be("/setup"); + + // Setup folder has index.md and advanced TOC + setupFolder.NavigationItems.Should().HaveCount(1); + + var advancedToc = setupFolder.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + // Verify the URL is /setup/advanced and not /setup/setup/advanced + advancedToc.Url.Should().Be("/setup/advanced"); + + // Advanced TOC has index.md and performance TOC + advancedToc.NavigationItems.Should().HaveCount(1); + + var performanceToc = advancedToc.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + // Verify the URL is /setup/advanced/performance and not /setup/advanced/setup/advanced/performance + performanceToc.Url.Should().Be("/setup/advanced/performance"); + + context.Diagnostics.Should().BeEmpty(); + } + + [Fact] + public void AllNavigationItemsHaveNavigationRootSet() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: index.md + - folder: setup + children: + - file: getting-started.md + - toc: advanced + - file: guide.md + children: + - file: section1.md + - file: section2.md + - title: "External" + crosslink: other://link.md + """; + + // language=yaml + var advancedTocYaml = """ + toc: + - file: index.md + - file: configuration.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddFile("/docs/setup/getting-started.md", new MockFileData("# Getting Started")); + fileSystem.AddFile("/docs/setup/advanced/index.md", new MockFileData("# Advanced")); + fileSystem.AddFile("/docs/setup/advanced/configuration.md", new MockFileData("# Configuration")); + fileSystem.AddFile("/docs/setup/advanced/toc.yml", new MockFileData(advancedTocYaml)); + + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + + // Helper to recursively visit all navigation items + var allItems = new List(); + void VisitNavigationItems(INavigationItem item) + { + allItems.Add(item); + if (item is INodeNavigationItem node) + { + allItems.AddRange(node.Index); + foreach (var child in node.NavigationItems) + VisitNavigationItems(child); + } + } + + // Add root and visit all children + allItems.Add(navigation); + foreach (var item in navigation.NavigationItems) + VisitNavigationItems(item); + + // Verify we have a good number of items (should be > 10 with all the nested structure) + allItems.Should().HaveCountGreaterThan(10); + + // Verify EVERY navigation item has NavigationRoot set (not null) + foreach (var item in allItems) + { + item.NavigationRoot.Should().NotBeNull($"NavigationRoot should be set for {item.GetType().Name} at URL: {item.Url}"); + + // Verify NavigationRoot is actually a root item + item.NavigationRoot.Should().BeAssignableTo>( + $"NavigationRoot should be a root navigation item for {item.GetType().Name} at URL: {item.Url}"); + } + + // Verify specific cases: + + // All items should point to either DocumentationSetNavigation or a TableOfContentsNavigation as root + var rootItem = navigation.NavigationItems.First(); + rootItem.NavigationRoot.Should().BeSameAs(navigation, "Root navigation items should point to DocumentationSetNavigation"); + + // Items in the setup folder should point to DocumentationSetNavigation + var setupFolder = navigation.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + setupFolder.NavigationRoot.Should().BeSameAs(navigation); + + var gettingStarted = setupFolder.NavigationItems.First(); + gettingStarted.NavigationRoot.Should().BeSameAs(navigation); + + // According to url-building.md: "In isolated builds the NavigationRoot is always the DocumentationSetNavigation" + // ALL items including TOCs should point to DocumentationSetNavigation as NavigationRoot + var advancedToc = setupFolder.NavigationItems.ElementAt(0).Should().BeOfType>().Subject; + advancedToc.NavigationRoot.Should().BeSameAs(navigation, "TOC NavigationRoot should be DocumentationSetNavigation in isolated builds"); + + var advancedIndex = advancedToc.NavigationItems.First(); + advancedIndex.NavigationRoot.Should().BeSameAs(navigation, "TOC children should point to DocumentationSetNavigation in isolated builds"); + + // Items in file with children should point to DocumentationSetNavigation + var guideFile = navigation.NavigationItems.ElementAt(1).Should().BeOfType>().Subject; + guideFile.NavigationRoot.Should().BeSameAs(navigation); + + var section1 = guideFile.NavigationItems.First(); + section1.NavigationRoot.Should().BeSameAs(navigation); + + // CrossLink should point to DocumentationSetNavigation + var crosslink = navigation.NavigationItems.ElementAt(2).Should().BeOfType().Subject; + crosslink.NavigationRoot.Should().BeSameAs(navigation); + + context.Diagnostics.Should().BeEmpty(); + } +} diff --git a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs new file mode 100644 index 000000000..44f09081d --- /dev/null +++ b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs @@ -0,0 +1,231 @@ +// 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; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Navigation.Isolated.Leaf; +using Elastic.Documentation.Navigation.Isolated.Node; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class PhysicalDocsetTests(ITestOutputHelper output) +{ + [Fact] + public async Task PhysicalDocsetCanBeNavigated() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + File.Exists(docsetPath).Should().BeTrue($"Expected docset file to exist at {docsetPath}"); + + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Assert navigation was built successfully + navigation.NavigationItems.Should().NotBeEmpty(); + + // Assert index.md is first + var firstItem = navigation.Index; + firstItem.Should().BeOfType>(); + firstItem.Url.Should().Be("/"); // index.md becomes / + + // Assert folders exist + var folders = navigation.NavigationItems.OfType>().ToList(); + folders.Should().NotBeEmpty(); + + // Check by URL since folder names derive from index file titles + var folderUrls = folders.Select(f => f.Url).ToList(); + folderUrls.Should().Contain("/contribute"); + + // No errors or warnings should be emitted during navigation construction + // Hints are acceptable for best practice guidance + context.Collector.Errors.Should().Be(0, "no errors should be emitted"); + context.Collector.Warnings.Should().Be(0, "no warnings should be emitted"); + + // Verify that the hint about deep-linking virtual file was emitted + var hints = context.Diagnostics.Where(d => d.Severity == Severity.Hint).ToList(); + hints.Should().Contain(d => + d.Message.Contains("nest-under-index/index.md") && + d.Message.Contains("deep-linking"), + "should emit hint for deep-linking virtual file"); + } + + [Fact] + public async Task PhysicalDocsetNavigationHasCorrectUrls() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Find the contribute folder by URL + var contributeFolder = navigation.NavigationItems.OfType>() + .FirstOrDefault(f => f.Url == "/contribute"); + contributeFolder.Should().NotBeNull(); + + // Verify nested structure + contributeFolder.NavigationItems.Should().NotBeEmpty(); + } + + [Fact] + public async Task PhysicalDocsetNavigationIncludesNestedTocs() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + var fileRefs = docSet.TableOfContents.SelectMany(DocumentationSetFile.GetFileRefs).ToList(); + foreach (var fileRef in fileRefs) + { + var path = fileSystem.FileInfo.New(Path.Combine(configPath.Directory!.FullName, fileRef.PathRelativeToDocumentationSet)); + path.Exists.Should().BeTrue($"Expected file {path.FullName} to exist"); + } + fileRefs.Count.Should().Be(fileRefs.Distinct().Count(), "should not have duplicate file references"); + + // Find TOC references in the navigation + var tocNavs = navigation.NavigationItems.OfType>().ToList(); + tocNavs.Should().NotBeEmpty(); + + // development TOC should exist (check by URL) + var developmentToc = tocNavs.FirstOrDefault(t => t.Url == "/development"); + developmentToc.Should().NotBeNull(); + + developmentToc.NavigationItems.Should().HaveCount(3); + developmentToc.Index.Should().NotBeNull(); + developmentToc.NavigationItems.OfType>().Should().HaveCount(0); + developmentToc.NavigationItems.OfType>().Should().HaveCount(2); + developmentToc.NavigationItems.OfType>().Should().HaveCount(1); + + var developmentIndex = developmentToc.Index as FileNavigationLeaf; + developmentIndex.Should().NotBeNull(); + developmentIndex.FileInfo.FullName.Should().Be(Path.Combine(docsDir.FullName, "development", "index.md")); + + + } + + [Fact] + public async Task PhysicalDocsetNavigationHandlesHiddenFiles() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, fileSystem); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Find hidden files + var allItems = GetAllNavigationItems(navigation.NavigationItems); + var hiddenItems = allItems.Where(i => i.Hidden).ToList(); + hiddenItems.Should().NotBeEmpty(); + } + + [Fact] + public async Task PhysicalDocsetNavigationHandlesCrossLinks() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance, crossLinkResolver: TestCrossLinkResolver.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Find cross-link items + var allItems = GetAllNavigationItems(navigation.NavigationItems); + var crossLinks = allItems.OfType().ToList(); + crossLinks.Should().NotBeEmpty(); + } + + [Fact] + public void CovarianceOfNavigationItemsIsRespected() + { + var docsetPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "_docset.yml"); + var fileSystem = new FileSystem(); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs")); + var outputDir = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "test-output")); + var configPath = fileSystem.FileInfo.New(docsetPath); + + var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath); + + var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); + + // Find cross-link items + var baseInterfaces = QueryAllAdheringTo(navigation); + var interfaces = QueryAllAdheringTo(navigation); + // ReSharper disable once RedundantTypeArgumentsOfMethod + var concrete = QueryAllAdheringTo(navigation); + + baseInterfaces.Count.Should().Be(interfaces.Count); + interfaces.Count.Should().Be(concrete.Count); + } + + private static List QueryAllAdheringTo(INodeNavigationItem navigation) + where TModel : class, INavigationModel + { + var result = new List { navigation, navigation.Index }; + foreach (var item in navigation.NavigationItems) + { + result.Add(item); + if (item is INodeNavigationItem node) + result.AddRange(QueryAllAdheringTo(node)); + } + return result; + } + + private static List GetAllNavigationItems(IReadOnlyCollection items) + { + var result = new List(); + foreach (var item in items) + { + result.Add(item); + if (item is INodeNavigationItem node) + result.AddRange(GetAllNavigationItems(node.NavigationItems)); + } + return result; + } +} diff --git a/tests/Navigation.Tests/Isolation/ValidationTests.cs b/tests/Navigation.Tests/Isolation/ValidationTests.cs new file mode 100644 index 000000000..a31b04e94 --- /dev/null +++ b/tests/Navigation.Tests/Isolation/ValidationTests.cs @@ -0,0 +1,286 @@ +// 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 Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation.Isolated; +using Elastic.Documentation.Navigation.Isolated.Node; +using FluentAssertions; + +namespace Elastic.Documentation.Navigation.Tests.Isolation; + +public class ValidationTests(ITestOutputHelper output) : DocumentationSetNavigationTestBase(output) +{ + [Fact] + public async Task ValidationEmitsErrorWhenTableOfContentsHasNonTocChildrenAndNestedTocNotAllowed() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - toc: api + children: + - toc: nested-toc + children: + - file: should-error.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/api"); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Message.Contains("may not contain children, define children in") && + d.Message.Contains("toc.yml")); + } + + [Fact] + public async Task ValidationEmitsErrorWhenTableOfContentsHasNonTocChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - toc: api + children: + - file: should-error.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Check using Errors count instead of Diagnostics collection + context.Collector.Errors.Should().BeGreaterThan(0); + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Message.Contains("may not contain children, define children in") && + d.Message.Contains("toc.yml")); + } + + [Fact] + public void ValidationEmitsErrorForNestedTocWithFileChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: setup + children: + - toc: advanced + children: + - toc: performance + children: + - file: index.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/setup/advanced"); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + // Nested TOC under a root-level TOC should not allow file children + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Message.Contains("may not contain children, define children in") && + d.Message.Contains("toc.yml")); + } + + [Fact] + public async Task ValidationEmitsErrorForDeeplyNestedFolderWithInvalidTocStructure() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - folder: docs + children: + - folder: guides + children: + - toc: api + children: + - toc: endpoints + children: + - file: users.md + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/docs/guides/api"); + fileSystem.AddDirectory("/docs"); + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + // Nested TOC structure under folders should still validate correctly + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Message.Contains("may not contain children, define children in") && + d.Message.Contains("toc.yml")); + } + + [Fact] + public async Task ValidationEmitsErrorWhenTocYmlFileNotFound() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - toc: api + """; + + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("/docs/api"); + // Note: not adding /docs/api/toc.yml file + var context = CreateContext(fileSystem); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, yaml, fileSystem.NewDirInfo("docs")); + _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + var diagnostics = context.Diagnostics; + diagnostics.Should().ContainSingle(d => + d.Message.Contains("Table of contents file not found") && + d.Message.Contains("api/toc.yml")); + } + + [Fact] + public async Task ValidationEmitsHintForDeepLinkingVirtualFiles() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: a/b/c/getting-started.md + children: + - file: a/b/c/setup.md + - file: a/b/c/install.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + context.Collector.Hints.Should().BeGreaterThan(0, "should have emitted a hint for deep-linking virtual file"); + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Severity == Severity.Hint && + d.Message.Contains("a/b/c/getting-started.md") && + d.Message.Contains("deep-linking") && + d.Message.Contains("folder")); + } + + [Fact] + public async Task ValidationEmitsHintForNestedPathVirtualFiles() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guides/api/overview.md + children: + - file: guides/api/authentication.md + - file: guides/api/endpoints.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + context.Collector.Hints.Should().BeGreaterThan(0); + var diagnostics = context.Diagnostics; + diagnostics.Should().Contain(d => + d.Severity == Severity.Hint && + d.Message.Contains("guides/api/overview.md") && + d.Message.Contains("Virtual files are primarily intended to group sibling files together")); + } + + [Fact] + public async Task ValidationDoesNotEmitHintForSimpleVirtualFiles() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: guide.md + children: + - file: chapter1.md + - file: chapter2.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + context.Collector.Hints.Should().Be(0, "simple virtual files without deep-linking should not trigger hints"); + context.Diagnostics.Should().BeEmpty(); + } + + [Fact] + public async Task ValidationDoesNotEmitHintForFilesWithoutChildren() + { + // language=yaml + var yaml = """ + project: 'test-project' + toc: + - file: a/b/c/getting-started.md + - file: guides/setup.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); + + _ = new DocumentationSetNavigation(docSet, context, GenericDocumentationFileFactory.Instance); + + await context.Collector.StopAsync(TestContext.Current.CancellationToken); + + context.Collector.Hints.Should().Be(0, "files without children should not trigger hints, even with deep paths"); + context.Diagnostics.Should().BeEmpty(); + } +} diff --git a/tests/Navigation.Tests/Navigation.Tests.csproj b/tests/Navigation.Tests/Navigation.Tests.csproj new file mode 100644 index 000000000..a3c1bded4 --- /dev/null +++ b/tests/Navigation.Tests/Navigation.Tests.csproj @@ -0,0 +1,23 @@ + + + + net9.0 + enable + enable + Elastic.Documentation.Navigation.Tests + + + + + + + + + + + + + + + + diff --git a/tests/Navigation.Tests/TestDocumentationSetContext.cs b/tests/Navigation.Tests/TestDocumentationSetContext.cs new file mode 100644 index 000000000..d7fb32605 --- /dev/null +++ b/tests/Navigation.Tests/TestDocumentationSetContext.cs @@ -0,0 +1,162 @@ +// 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.Diagnostics.CodeAnalysis; +using System.IO.Abstractions; +using Elastic.Documentation; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Links.CrossLinks; +using Elastic.Documentation.Navigation.Isolated; +using Markdig; +using Markdig.Parsers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace Elastic.Documentation.Navigation.Tests; + +public class TestDiagnosticsOutput(ITestOutputHelper output) : IDiagnosticsOutput +{ + public void Write(Diagnostic diagnostic) + { + if (diagnostic.Severity == Severity.Error) + output.WriteLine($"Error: {diagnostic.Message} ({diagnostic.File}:{diagnostic.Line})"); + else + output.WriteLine($"Warn : {diagnostic.Message} ({diagnostic.File}:{diagnostic.Line})"); + } +} + +public class TestDiagnosticsCollector(ITestOutputHelper output) + : DiagnosticsCollector([new TestDiagnosticsOutput(output)]) +{ + private readonly List _diagnostics = []; + + public IReadOnlyCollection Diagnostics => _diagnostics; + + /// + public override void Write(Diagnostic diagnostic) + { + IncrementSeverityCount(diagnostic); + _diagnostics.Add(diagnostic); + } + + /// + public override DiagnosticsCollector StartAsync(Cancel ctx) => this; + + /// + public override Task StopAsync(Cancel cancellationToken) => Task.CompletedTask; +} + +/// A cross link resolver that always resolves to a fixed URL +public class TestCrossLinkResolver : ICrossLinkResolver +{ + public static TestCrossLinkResolver Instance { get; } = new(); + + /// + public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) + { + resolvedUri = new Uri("https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main"); + return true; + } + + /// + public IUriEnvironmentResolver UriResolver { get; } = new IsolatedBuildEnvironmentUriResolver(); + + private TestCrossLinkResolver() { } + +} + +public class TestDocumentationSetContext : IDocumentationSetContext +{ + public TestDocumentationSetContext(IFileSystem fileSystem, + IDirectoryInfo sourceDirectory, + IDirectoryInfo outputDirectory, + IFileInfo configPath, + ITestOutputHelper output, + string? repository = null, + TestDiagnosticsCollector? collector = null + ) + { + ReadFileSystem = fileSystem; + WriteFileSystem = fileSystem; + DocumentationSourceDirectory = sourceDirectory; + OutputDirectory = outputDirectory; + ConfigurationPath = configPath; + Collector = collector ?? new TestDiagnosticsCollector(output); + Git = repository is null ? GitCheckoutInformation.Unavailable : new GitCheckoutInformation + { + Branch = "main", + Remote = $"elastic/{repository}", + Ref = "main", + RepositoryName = repository + }; + + // Start the diagnostics collector to process messages + _ = Collector.StartAsync(Cancel.None); + } + + public IDiagnosticsCollector Collector { get; } + public IFileSystem ReadFileSystem { get; } + public IFileSystem WriteFileSystem { get; } + public IDirectoryInfo OutputDirectory { get; } + public IDirectoryInfo DocumentationSourceDirectory { get; } + public GitCheckoutInformation Git { get; } + public IFileInfo ConfigurationPath { get; } + + /// + public bool AssemblerBuild { get; set; } + + public IReadOnlyCollection Diagnostics => ((TestDiagnosticsCollector)Collector).Diagnostics; +} + +public class TestDocumentationFile(string navigationTitle) : IDocumentationFile +{ + /// + public string NavigationTitle { get; } = navigationTitle; +} + +public class TestDocumentationFileFactory : IDocumentationFileFactory +{ + // Preserve concrete type to leverage covariance properly + public static TestDocumentationFileFactory Instance { get; } = new(); + + /// + public TestDocumentationFile TryCreateDocumentationFile(IFileInfo path, IFileSystem readFileSystem) + { + // Extract the title from the file name (without extension) + var fileName = path.Name; + var title = fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? fileName[..^3] + : fileName; + return new TestDocumentationFile(title); + } +} + +// Factory that creates base IDocumentationFile instances for tests that don't need concrete types +public class GenericDocumentationFileFactory : IDocumentationFileFactory +{ + public static GenericDocumentationFileFactory Instance { get; } = new(); + + /// + public IDocumentationFile TryCreateDocumentationFile(IFileInfo path, IFileSystem readFileSystem) + { + // Extract the title from the file name (without extension) + var fileName = path.Name; + var title = fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? fileName[..^3] + : fileName; + if (path.Exists) + { + var text = readFileSystem.File.ReadAllText(path.FullName); + var md = MarkdownParser.Parse(text); + var header = md.OfType().FirstOrDefault(); + var inline = header?.Inline?.OfType().FirstOrDefault()?.Content.Text; + if (inline != null) + title = inline.Trim(['#', ' ']); + } + + + return new TestDocumentationFile(title); + } +} diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs index 97d2145c6..629ab33ad 100644 --- a/tests/authoring/Framework/CrossLinkResolverAssertions.fs +++ b/tests/authoring/Framework/CrossLinkResolverAssertions.fs @@ -34,6 +34,7 @@ module CrossLinkResolverAssertions = member _.WriteFileSystem = mockFileSystem member _.ConfigurationPath = mockFileSystem.FileInfo.New("mock_docset.yml") member _.OutputDirectory = mockFileSystem.DirectoryInfo.New(".artifacts") + member _.AssemblerBuild = false } let redirectFileParser = RedirectFile(docContext, mockRedirectsFile) redirectFileParser.Redirects diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index 6876c4fa2..4eedfc5dc 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -63,12 +63,18 @@ type Setup = yaml.WriteLine(" - docs-content") yaml.WriteLine(" - elasticsearch") yaml.WriteLine(" - kibana") + yaml.WriteLine("exclude:") + yaml.WriteLine(" - '_*.md'") yaml.WriteLine("toc:") let markdownFiles = fileSystem.Directory.EnumerateFiles(root.FullName, "*.md", SearchOption.AllDirectories) markdownFiles |> Seq.iter(fun markdownFile -> let relative = fileSystem.Path.GetRelativePath(root.FullName, markdownFile); - yaml.WriteLine($" - file: {relative}"); + // Skip files that match the exclusion pattern (any path segment starting with _) + let pathSegments = relative.Split([|'/'; '\\'|], StringSplitOptions.RemoveEmptyEntries) + let shouldExclude = pathSegments |> Array.exists (fun segment -> segment.StartsWith("_")) + if not shouldExclude then + yaml.WriteLine($" - file: {relative}"); ) let redirectFiles = ["5th-page"; "second-page"; "third-page"; "first-page"] redirectFiles diff --git a/tests/authoring/Framework/TestValues.fs b/tests/authoring/Framework/TestValues.fs index 8ec8e319c..b2be8ff49 100644 --- a/tests/authoring/Framework/TestValues.fs +++ b/tests/authoring/Framework/TestValues.fs @@ -110,7 +110,7 @@ and MarkdownTestContext = let file = kv.Value.File let document = kv.Value.Document let html = kv.Value.Html - let! minimal = kv.Value.File.MinimalParseAsync(ctx) + let! minimal = kv.Value.File.MinimalParseAsync((fun s -> this.Set.TryFindDocumentByRelativePath s), ctx) return { File = file; Document = document; MinimalParse = minimal; Html = html; Context = this } }) // this is not great code, refactor or depend on FSharp.Control.TaskSeq diff --git a/tests/authoring/Generator/LinkReferenceFile.fs b/tests/authoring/Generator/LinkReferenceFile.fs index 8f4e65a1e..0515962d8 100644 --- a/tests/authoring/Generator/LinkReferenceFile.fs +++ b/tests/authoring/Generator/LinkReferenceFile.fs @@ -64,24 +64,24 @@ Through various means $$$including-this-inline-syntax$$$ "url_path_prefix": "", "links": { "file.md": {}, + "index.md": { + "anchors": [ + "including-this-inline-syntax", + "this-anchor-is-autogenerated", + "and-anchored" + ] + }, "testing/redirects/5th-page.md": { "anchors": [ "bb" ] }, + "testing/redirects/first-page.md": { + "anchors": [ "has-an-anchor-as-well" ] + }, "testing/redirects/second-page.md": { "anchors": [ "active-anchor", "zz", "yy" ] }, "testing/redirects/third-page.md": { "anchors": [ "bb" ] - }, - "testing/redirects/first-page.md": { - "anchors": [ "has-an-anchor-as-well" ] - }, - "index.md": { - "anchors": [ - "including-this-inline-syntax", - "this-anchor-is-autogenerated", - "and-anchored" - ] } }, "cross_links": [],