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
[](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 @@
+
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 @@
+
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** - Root of a repository
+-  **TableOfContentsNavigation** - A `toc.yml` section
+-  **FolderNavigation** - A directory
+-  **FileNavigationLeaf** - A markdown file
+-  **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
+
+
+
+**What the diagram shows:**
+
+-  `elastic-project://` - Repository root
+-  `api/` and `guides/` - Sections from `toc.yml` files
+-  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
+
+
+
+**What the diagram shows:**
+
+-  `site://` - Root of entire assembled site
+-  `elastic-project://api` - Re-homed from `/api/` to `/elasticsearch/api/`
+-  `elastic-project://guides` - Re-homed from `/guides/` to `/elasticsearch/guides/`
+-  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