+@using Elastic.ApiExplorer
@using Elastic.ApiExplorer.Landing
@using Elastic.ApiExplorer.Operations
@using Elastic.ApiExplorer.Schemas
@@ -85,6 +86,13 @@
@schemaItem.Model.SchemaId |
}
+ else if (navigationItem is SimpleMarkdownNavigationItem markdownPage)
+ {
+
+ | @(markdownPage.NavigationTitle) |
+ Additional documentation |
+
+ }
else
{
throw new Exception($"Unexpected type: {navigationItem.GetType().FullName}");
diff --git a/src/Elastic.ApiExplorer/Landing/MarkdownPageView.cshtml b/src/Elastic.ApiExplorer/Landing/MarkdownPageView.cshtml
new file mode 100644
index 000000000..06b715d97
--- /dev/null
+++ b/src/Elastic.ApiExplorer/Landing/MarkdownPageView.cshtml
@@ -0,0 +1,10 @@
+@inherits RazorSliceHttpResult
+@using Elastic.ApiExplorer
+@using Elastic.ApiExplorer.Landing
+@implements IUsesLayout
+@functions {
+ public ApiLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel();
+}
+
diff --git a/src/Elastic.ApiExplorer/Landing/MarkdownPageViewModel.cs b/src/Elastic.ApiExplorer/Landing/MarkdownPageViewModel.cs
new file mode 100644
index 000000000..f9a652206
--- /dev/null
+++ b/src/Elastic.ApiExplorer/Landing/MarkdownPageViewModel.cs
@@ -0,0 +1,20 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using Microsoft.AspNetCore.Html;
+
+namespace Elastic.ApiExplorer.Landing;
+
+///
+/// View model for intro/outro markdown pages in the API Explorer layout.
+///
+public class MarkdownPageViewModel(ApiRenderContext context) : ApiViewModel(context)
+{
+ public required string PageTitle { get; init; }
+
+ public required HtmlString BodyHtml { get; init; }
+
+ ///
+ protected override string? LayoutPageTitle => PageTitle;
+}
diff --git a/src/Elastic.ApiExplorer/Landing/TemplateLanding.cs b/src/Elastic.ApiExplorer/Landing/TemplateLanding.cs
deleted file mode 100644
index 965369a6d..000000000
--- a/src/Elastic.ApiExplorer/Landing/TemplateLanding.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-// Licensed to Elasticsearch B.V under one or more agreements.
-// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
-// See the LICENSE file in the project root for more information
-
-using System.IO.Abstractions;
-using Elastic.Documentation.Extensions;
-using Microsoft.OpenApi;
-using RazorSlices;
-
-namespace Elastic.ApiExplorer.Landing;
-
-///
-/// Template-based API landing page model.
-///
-public class TemplateLanding(string templateContent) : IApiGroupingModel
-{
- ///
- /// The processed template content as HTML.
- ///
- public string TemplateContent { get; } = templateContent;
-
- ///
- /// Renders the template-based landing page.
- ///
- public async Task RenderAsync(FileSystemStream stream, ApiRenderContext context, Cancel ctx = default)
- {
- if (context?.Model == null)
- throw new ArgumentNullException(nameof(context), "Context or context.Model cannot be null");
-
- var viewModel = new TemplateLandingViewModel(context)
- {
- Landing = this,
- TemplateContent = TemplateContent,
- ApiInfo = context.Model.Info ?? new() { Title = "API Documentation", Version = "1.0" }
- };
- var slice = TemplateLandingView.Create(viewModel);
- await slice.RenderAsync(stream, cancellationToken: ctx);
- }
-}
diff --git a/src/Elastic.ApiExplorer/Landing/TemplateLandingView.cshtml b/src/Elastic.ApiExplorer/Landing/TemplateLandingView.cshtml
deleted file mode 100644
index 9dc1bde51..000000000
--- a/src/Elastic.ApiExplorer/Landing/TemplateLandingView.cshtml
+++ /dev/null
@@ -1,21 +0,0 @@
-@inherits RazorSliceHttpResult
-@using Elastic.ApiExplorer.Landing
-@using Elastic.Documentation.Navigation
-@using Elastic.Documentation.Site.Navigation
-@using Microsoft.AspNetCore.Html
-@implements IUsesLayout
-@functions {
- public ApiLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel();
-}
-
-
- @if (!string.IsNullOrEmpty(Model.TemplateContent))
- {
- @(new HtmlString(Model.TemplateContent))
- }
- else
- {
- API Documentation
- Welcome to the API documentation.
- }
-
diff --git a/src/Elastic.ApiExplorer/Landing/TemplateLandingViewModel.cs b/src/Elastic.ApiExplorer/Landing/TemplateLandingViewModel.cs
deleted file mode 100644
index e828b8cab..000000000
--- a/src/Elastic.ApiExplorer/Landing/TemplateLandingViewModel.cs
+++ /dev/null
@@ -1,28 +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 Microsoft.OpenApi;
-
-namespace Elastic.ApiExplorer.Landing;
-
-///
-/// View model for template-based API landing pages.
-///
-public class TemplateLandingViewModel(ApiRenderContext context) : ApiViewModel(context)
-{
- ///
- /// The template landing model.
- ///
- public required TemplateLanding Landing { get; init; }
-
- ///
- /// The processed template content as HTML.
- ///
- public required string TemplateContent { get; init; }
-
- ///
- /// The API information from the OpenAPI specification.
- ///
- public required OpenApiInfo ApiInfo { get; init; }
-}
diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs
index a68062c9a..d95a75858 100644
--- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs
+++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs
@@ -7,7 +7,6 @@
using Elastic.ApiExplorer.Landing;
using Elastic.ApiExplorer.Operations;
using Elastic.ApiExplorer.Schemas;
-using Elastic.ApiExplorer.Templates;
using Elastic.Documentation;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Toc;
@@ -46,9 +45,8 @@ public class OpenApiGenerator(ILoggerFactory logFactory, BuildContext context, I
private readonly ILogger _logger = logFactory.CreateLogger();
private readonly IFileSystem _writeFileSystem = context.WriteFileSystem;
private readonly StaticFileContentHashProvider _contentHashProvider = new(new EmbeddedOrPhysicalFileProvider(context));
- private readonly TemplateProcessor _templateProcessor = TemplateProcessorFactory.Create(markdownStringRenderer, context.ReadFileSystem);
- public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocument openApiDocument)
+ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocument openApiDocument, ResolvedApiConfiguration? apiConfig = null)
{
var url = $"{context.UrlPathPrefix}/api/" + apiUrlSuffix;
var rootNavigation = new LandingNavigationItem(url);
@@ -134,16 +132,151 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume
// Add schema type pages for shared types
CreateSchemaNavigationItems(apiUrlSuffix, openApiDocument, rootNavigation, topLevelNavigationItems);
- // Multi-tag / multi-classification builds into topLevelNavigationItems; single-tag writes endpoints
- // directly onto root. Assigning topLevel here must not wipe root when that list is still empty.
- if (topLevelNavigationItems.Count > 0 && rootNavigation.NavigationItems.Count == 0)
- rootNavigation.NavigationItems = topLevelNavigationItems;
- else if (topLevelNavigationItems.Count > 0 && rootNavigation.NavigationItems.Count > 0)
- rootNavigation.NavigationItems = [.. rootNavigation.NavigationItems, .. topLevelNavigationItems];
+ // Collect operation monikers for collision detection
+ var operationMonikers = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var path in openApiDocument.Paths)
+ {
+ foreach (var operation in path.Value.Operations ?? [])
+ {
+ // Use same moniker logic as OperationNavigationItem
+ var moniker = !string.IsNullOrWhiteSpace(operation.Value.OperationId)
+ ? operation.Value.OperationId
+ : path.Key.Replace("}", "").Replace("{", "").Replace('/', '-');
+ _ = operationMonikers.Add(moniker);
+ }
+ }
+
+ // Add intro and outro markdown pages if available
+ var finalNavigationItems = new List();
+ var markdownSlugs = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ // Add intro pages first
+ if (apiConfig?.IntroMarkdownFiles.Count > 0)
+ {
+ foreach (var introFile in apiConfig.IntroMarkdownFiles)
+ {
+ var introNavItem = CreateMarkdownNavigationItem(apiUrlSuffix, introFile, rootNavigation, rootNavigation, operationMonikers, markdownSlugs);
+ finalNavigationItems.Add(introNavItem);
+ }
+ }
+
+ // Add existing navigation items (OpenAPI generated content)
+ if (topLevelNavigationItems.Count > 0)
+ finalNavigationItems.AddRange(topLevelNavigationItems);
+ else if (rootNavigation.NavigationItems.Count > 0)
+ finalNavigationItems.AddRange(rootNavigation.NavigationItems);
+
+ // Add outro pages last
+ if (apiConfig?.OutroMarkdownFiles.Count > 0)
+ {
+ foreach (var outroFile in apiConfig.OutroMarkdownFiles)
+ {
+ var outroNavItem = CreateMarkdownNavigationItem(apiUrlSuffix, outroFile, rootNavigation, rootNavigation, operationMonikers, markdownSlugs);
+ finalNavigationItems.Add(outroNavItem);
+ }
+ }
+
+ // Set the final navigation items
+ if (finalNavigationItems.Count > 0)
+ rootNavigation.NavigationItems = finalNavigationItems;
return rootNavigation;
}
+ private SimpleMarkdownNavigationItem CreateMarkdownNavigationItem(
+ string apiUrlSuffix,
+ IFileInfo markdownFile,
+ LandingNavigationItem rootNavigation,
+ INodeNavigationItem parent,
+ HashSet operationMonikers,
+ HashSet markdownSlugs)
+ {
+ var slug = SimpleMarkdownNavigationItem.CreateSlugFromFile(markdownFile);
+
+ // Check for duplicate markdown slugs
+ if (!markdownSlugs.Add(slug))
+ {
+ throw new InvalidOperationException(
+ $"Duplicate markdown slug '{slug}' found in API product '{apiUrlSuffix}'. " +
+ $"File: {markdownFile.FullName}");
+ }
+
+ SimpleMarkdownNavigationItem.ValidateSlugForCollisions(slug, apiUrlSuffix, markdownFile.FullName, operationMonikers);
+
+ var url = $"{context.UrlPathPrefix}/api/{apiUrlSuffix}/{slug}/";
+ var title = GetNavigationTitleFromFile(markdownFile);
+
+ // Create simple navigation item - will be handled by regular documentation system
+ var navItem = new SimpleMarkdownNavigationItem(url, title, markdownFile, rootNavigation)
+ {
+ Parent = parent
+ };
+
+ return navItem;
+ }
+
+ private string GetNavigationTitleFromFile(IFileInfo markdownFile)
+ {
+ try
+ {
+ // Read file content to parse frontmatter
+ var content = context.ReadFileSystem.File.ReadAllText(markdownFile.FullName);
+
+ // Simple frontmatter parsing - look for navigation_title
+ if (content.StartsWith("---"))
+ {
+ var lines = content.Split('\n');
+ var frontMatterEndIndex = -1;
+
+ for (var i = 1; i < lines.Length; i++)
+ {
+ if (lines[i].TrimStart().StartsWith("---"))
+ {
+ frontMatterEndIndex = i;
+ break;
+ }
+ }
+
+ if (frontMatterEndIndex > 0)
+ {
+ for (var i = 1; i < frontMatterEndIndex; i++)
+ {
+ var line = lines[i].Trim();
+ if (line.StartsWith("navigation_title:"))
+ {
+ var title = line.Substring("navigation_title:".Length).Trim();
+ // Remove quotes if present
+ if ((title.StartsWith('"') && title.EndsWith('"')) ||
+ (title.StartsWith('\'') && title.EndsWith('\'')))
+ {
+ title = title.Substring(1, title.Length - 2);
+ }
+ if (!string.IsNullOrEmpty(title))
+ {
+ return title;
+ }
+ }
+ }
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // Fall back to filename-based title if parsing fails
+ }
+
+ // Fallback: Extract a friendly navigation title from the filename
+ var fileName = Path.GetFileNameWithoutExtension(markdownFile.Name);
+
+ // Convert kebab-case/snake_case to title case
+ return fileName
+ .Replace('-', ' ')
+ .Replace('_', ' ')
+ .Split(' ', StringSplitOptions.RemoveEmptyEntries)
+ .Select(word => char.ToUpper(word[0]) + word[1..].ToLower())
+ .Aggregate((current, next) => $"{current} {next}");
+ }
+
private void CreateTagNavigationItems(
string apiUrlSuffix,
ApiClassification classification,
@@ -243,25 +376,16 @@ public async Task Generate(Cancel ctx = default)
private async Task GenerateApiProduct(string prefix, OpenApiDocument openApiDocument, ResolvedApiConfiguration? apiConfig, Cancel ctx)
{
- var navigation = CreateNavigation(prefix, openApiDocument);
+ var navigation = CreateNavigation(prefix, openApiDocument, apiConfig);
_logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? "");
var navigationRenderer = new IsolatedBuildNavigationHtmlWriter(context, navigation);
- TemplateLanding? templateLanding = null;
- if (apiConfig?.HasCustomTemplate == true)
- {
- var templateContent = await _templateProcessor.ProcessTemplateAsync(apiConfig, ctx);
- if (!string.IsNullOrWhiteSpace(templateContent))
- templateLanding = new TemplateLanding(templateContent);
- }
-
var renderContext = new ApiRenderContext(context, openApiDocument, _contentHashProvider)
{
NavigationHtml = string.Empty,
CurrentNavigation = navigation,
- MarkdownRenderer = markdownStringRenderer,
- TemplateLandingPage = templateLanding
+ MarkdownRenderer = markdownStringRenderer
};
await RenderNavigationItems(prefix, renderContext, navigationRenderer, navigation, navigation, ctx);
@@ -277,9 +401,7 @@ private async Task RenderNavigationItems(
{
if (currentNavigation is INodeNavigationItem node)
{
- _ = renderContext.TemplateLandingPage is { } templateLanding && ReferenceEquals(currentNavigation, rootNavigation)
- ? await Render(prefix, node, templateLanding, renderContext, navigationRenderer, ctx)
- : await Render(prefix, node, node.Index.Model, 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, rootNavigation, ctx);
diff --git a/src/Elastic.ApiExplorer/SimpleMarkdownNavigationItem.cs b/src/Elastic.ApiExplorer/SimpleMarkdownNavigationItem.cs
new file mode 100644
index 000000000..fe80558a9
--- /dev/null
+++ b/src/Elastic.ApiExplorer/SimpleMarkdownNavigationItem.cs
@@ -0,0 +1,80 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions;
+using Elastic.ApiExplorer.Landing;
+using Elastic.Documentation;
+using Elastic.Documentation.Navigation;
+using Microsoft.AspNetCore.Html;
+using RazorSlices;
+
+namespace Elastic.ApiExplorer;
+
+///
+/// Lightweight navigation entry for intro/outro markdown files listed under api:.
+/// These pages are rendered using the regular markdown processing pipeline and API Explorer layout.
+///
+public class SimpleMarkdownNavigationItem(
+ string url,
+ string title,
+ IFileInfo fileInfo,
+ IRootNavigationItem navigationRoot) : INavigationItem, IApiModel, ILeafNavigationItem
+{
+ public string Url { get; } = url;
+ public string NavigationTitle { get; } = title;
+ public IFileInfo FileInfo { get; } = fileInfo;
+ public IRootNavigationItem NavigationRoot { get; } = navigationRoot;
+ public INodeNavigationItem? Parent { get; set; }
+ public bool Hidden => false;
+ public int NavigationIndex { get; set; }
+ public string Id { get; } = $"markdown-{Path.GetFileNameWithoutExtension(fileInfo.Name)}";
+ public Uri Identifier { get; } = new("about:blank");
+ public string Slug { get; } = CreateSlugFromFile(fileInfo);
+
+ ///
+ public IApiModel Model => this;
+
+ /// Creates a URL slug from a markdown filename.
+ public static string CreateSlugFromFile(IFileInfo markdownFile)
+ {
+ var fileName = Path.GetFileNameWithoutExtension(markdownFile.Name);
+ return fileName.ToLowerInvariant()
+ .Replace(' ', '-')
+ .Replace('_', '-');
+ }
+
+ /// Throws if the slug collides with reserved API Explorer segments or an operation moniker.
+ public static void ValidateSlugForCollisions(string slug, string productKey, string filePath, HashSet? operationMonikers = null)
+ {
+ string[] reservedSegments = ["types", "tags"];
+
+ if (reservedSegments.Contains(slug, StringComparer.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ $"Markdown file slug '{slug}' (from '{filePath}') conflicts with reserved API Explorer segment in product '{productKey}'. Reserved segments: {string.Join(", ", reservedSegments)}");
+ }
+
+ if (operationMonikers != null && operationMonikers.Contains(slug))
+ {
+ throw new InvalidOperationException(
+ $"Markdown file slug '{slug}' (from '{filePath}') conflicts with existing operation moniker in product '{productKey}'. Consider renaming the markdown file to avoid this collision.");
+ }
+ }
+
+ ///
+ /// Renders the markdown file using the API Explorer layout system.
+ ///
+ public async Task RenderAsync(FileSystemStream stream, ApiRenderContext context, Cancel ctx = default)
+ {
+ var markdownContent = await context.BuildContext.ReadFileSystem.File.ReadAllTextAsync(FileInfo.FullName, ctx);
+ var htmlContent = context.MarkdownRenderer.RenderPreservingFirstHeading(markdownContent, FileInfo);
+ var viewModel = new MarkdownPageViewModel(context)
+ {
+ PageTitle = NavigationTitle,
+ BodyHtml = new HtmlString(htmlContent ?? string.Empty)
+ };
+ var slice = MarkdownPageView.Create(viewModel);
+ await slice.RenderAsync(stream, cancellationToken: ctx);
+ }
+}
diff --git a/src/Elastic.ApiExplorer/Templates/TemplateProcessor.cs b/src/Elastic.ApiExplorer/Templates/TemplateProcessor.cs
deleted file mode 100644
index 9fd026f18..000000000
--- a/src/Elastic.ApiExplorer/Templates/TemplateProcessor.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-// Licensed to Elasticsearch B.V under one or more agreements.
-// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
-// See the LICENSE file in the project root for more information
-
-using System.IO.Abstractions;
-using Elastic.Documentation;
-using Elastic.Documentation.Configuration.Toc;
-
-namespace Elastic.ApiExplorer.Templates;
-
-///
-/// Processes API landing page templates using standard markdown rendering.
-///
-public class TemplateProcessor(IMarkdownStringRenderer markdownRenderer, IFileSystem fileSystem)
-{
- private readonly IMarkdownStringRenderer _markdownRenderer = markdownRenderer;
- private readonly IFileSystem _fileSystem = fileSystem;
-
- ///
- /// Processes a template file for a specific API configuration.
- ///
- public async Task ProcessTemplateAsync(
- ResolvedApiConfiguration apiConfig,
- CancellationToken cancellationToken = default)
- {
- if (!apiConfig.HasCustomTemplate || apiConfig.TemplateFile == null)
- return string.Empty;
-
- // Read template content
- var templateContent = await _fileSystem.File.ReadAllTextAsync(apiConfig.TemplateFile.FullName, cancellationToken);
-
- // Check for cancellation before rendering
- if (cancellationToken.IsCancellationRequested)
- {
- cancellationToken.ThrowIfCancellationRequested();
- }
-
- // Template uses standard substitutions and directives - render directly.
- return _markdownRenderer.Render(templateContent, apiConfig.TemplateFile);
- }
-}
-
-///
-/// Factory for creating template processors with proper dependencies.
-///
-public class TemplateProcessorFactory
-{
- ///
- /// Creates a template processor with the required dependencies.
- ///
- public static TemplateProcessor Create(IMarkdownStringRenderer markdownRenderer, IFileSystem fileSystem) => new(markdownRenderer, fileSystem);
-}
diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs
index 401c58eaa..20c038bcc 100644
--- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs
+++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs
@@ -145,36 +145,58 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte
var specs = new Dictionary(StringComparer.OrdinalIgnoreCase);
var apiConfigs = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var (productKey, apiConfig) in docSetFile.Api)
+ foreach (var (productKey, apiSequence) in docSetFile.Api)
{
- if (!apiConfig.IsValid)
+ if (!apiSequence.IsValid)
{
context.EmitError(
context.ConfigurationPath,
- $"API configuration for '{productKey}' is invalid. Must have at least one spec and cannot specify both 'spec' and 'specs'."
+ $"API configuration for '{productKey}' is invalid. Must have at least one spec and all entries must be valid."
);
continue;
}
- // Resolve template file if specified
- IFileInfo? templateFile = null;
- if (!string.IsNullOrEmpty(apiConfig.Template))
+ // Resolve intro markdown files
+ var introMarkdownFiles = new List();
+ foreach (var introPath in apiSequence.GetIntroMarkdownFiles())
{
- var templatePath = Path.Join(context.DocumentationSourceDirectory.FullName, apiConfig.Template);
- templateFile = context.ReadFileSystem.FileInfo.New(templatePath);
- if (!templateFile.Exists)
+ var fullPath = Path.Join(context.DocumentationSourceDirectory.FullName, introPath);
+ var introFile = context.ReadFileSystem.FileInfo.New(fullPath);
+ if (!introFile.Exists)
{
context.EmitWarning(
context.ConfigurationPath,
- $"Template file '{apiConfig.Template}' for API '{productKey}' does not exist."
+ $"Intro markdown file '{introPath}' for API '{productKey}' does not exist."
);
- templateFile = null;
+ }
+ else
+ {
+ introMarkdownFiles.Add(introFile);
+ }
+ }
+
+ // Resolve outro markdown files
+ var outroMarkdownFiles = new List();
+ foreach (var outroPath in apiSequence.GetOutroMarkdownFiles())
+ {
+ var fullPath = Path.Join(context.DocumentationSourceDirectory.FullName, outroPath);
+ var outroFile = context.ReadFileSystem.FileInfo.New(fullPath);
+ if (!outroFile.Exists)
+ {
+ context.EmitWarning(
+ context.ConfigurationPath,
+ $"Outro markdown file '{outroPath}' for API '{productKey}' does not exist."
+ );
+ }
+ else
+ {
+ outroMarkdownFiles.Add(outroFile);
}
}
// Resolve specification files
var specFiles = new List();
- foreach (var specPath in apiConfig.GetSpecPaths())
+ foreach (var specPath in apiSequence.GetSpecPaths())
{
var fullPath = Path.Join(context.DocumentationSourceDirectory.FullName, specPath);
var specFile = context.ReadFileSystem.FileInfo.New(fullPath);
@@ -202,8 +224,9 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte
var resolvedConfig = new ResolvedApiConfiguration
{
ProductKey = productKey,
- TemplateFile = templateFile,
- SpecFiles = specFiles
+ IntroMarkdownFiles = introMarkdownFiles,
+ SpecFiles = specFiles,
+ OutroMarkdownFiles = outroMarkdownFiles
};
apiConfigs[productKey] = resolvedConfig;
diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs
index e06ccd7f6..925c5cec1 100644
--- a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs
+++ b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs
@@ -7,8 +7,114 @@
namespace Elastic.Documentation.Configuration.Toc;
+///
+/// Represents a single entry in an API product sequence (either a file or spec).
+///
+[YamlSerializable]
+public class ApiProductEntry
+{
+ ///
+ /// Path to a Markdown file for intro/outro content.
+ ///
+ [YamlMember(Alias = "file")]
+ public string? File { get; set; }
+
+ ///
+ /// Path to an OpenAPI specification file.
+ ///
+ [YamlMember(Alias = "spec")]
+ public string? Spec { get; set; }
+
+ ///
+ /// Whether this entry represents a Markdown file.
+ ///
+ public bool IsMarkdownFile => !string.IsNullOrWhiteSpace(File);
+
+ ///
+ /// Whether this entry represents an OpenAPI specification.
+ ///
+ public bool IsOpenApiSpec => !string.IsNullOrWhiteSpace(Spec);
+
+ ///
+ /// Gets the path for this entry (either file or spec).
+ ///
+ public string? GetPath() => File ?? Spec;
+
+ ///
+ /// Validates that this entry has exactly one of file or spec set.
+ ///
+ public bool IsValid => (IsMarkdownFile && !IsOpenApiSpec) || (!IsMarkdownFile && IsOpenApiSpec);
+}
+
+///
+/// Represents an API product configuration as a sequence of file/spec entries.
+///
+[YamlSerializable]
+public class ApiProductSequence
+{
+ ///
+ /// Ordered list of file and spec entries.
+ ///
+ public List Entries { get; set; } = [];
+
+ ///
+ /// Gets all Markdown file entries that appear before the first spec.
+ ///
+ public IEnumerable GetIntroMarkdownFiles()
+ {
+ foreach (var entry in Entries)
+ {
+ if (entry.IsOpenApiSpec)
+ break;
+ if (entry.IsMarkdownFile)
+ yield return entry.File!;
+ }
+ }
+
+ ///
+ /// Gets all Markdown file entries that appear after the last spec.
+ ///
+ public IEnumerable GetOutroMarkdownFiles()
+ {
+ var lastSpecIndex = -1;
+
+ // Find the last spec entry
+ for (var i = Entries.Count - 1; i >= 0; i--)
+ {
+ if (Entries[i].IsOpenApiSpec)
+ {
+ lastSpecIndex = i;
+ break;
+ }
+ }
+
+ // Return markdown files after the last spec
+ if (lastSpecIndex >= 0)
+ {
+ for (var i = lastSpecIndex + 1; i < Entries.Count; i++)
+ {
+ if (Entries[i].IsMarkdownFile)
+ yield return Entries[i].File!;
+ }
+ }
+ }
+
+ ///
+ /// Gets all OpenAPI specification file paths.
+ ///
+ public IEnumerable GetSpecPaths() => Entries
+ .Where(e => e.IsOpenApiSpec)
+ .Select(e => e.Spec!);
+
+ ///
+ /// Validates that the sequence has exactly one spec and all entries are valid.
+ ///
+ public bool IsValid => Entries.All(e => e.IsValid) && Entries.Count(e => e.IsOpenApiSpec) == 1;
+}
+
///
/// Configuration for API documentation generation from OpenAPI specifications.
+/// Legacy class maintained for backward compatibility.
///
[YamlSerializable]
public class ApiConfiguration
@@ -27,12 +133,6 @@ public class ApiConfiguration
[YamlMember(Alias = "specs")]
public List? Specs { get; set; }
- ///
- /// Path to a Markdown template file to use as the API landing page.
- /// If not specified, an auto-generated landing page will be used.
- ///
- [YamlMember(Alias = "template")]
- public string? Template { get; set; }
///
/// Validates that the configuration is valid.
@@ -63,16 +163,40 @@ public IEnumerable GetSpecPaths()
public class ResolvedApiConfiguration
{
public required string ProductKey { get; init; }
- public IFileInfo? TemplateFile { get; init; }
+
+ ///
+ /// Ordered list of Markdown files that appear before the first spec (intro content).
+ ///
+ public List IntroMarkdownFiles { get; init; } = [];
+
+ ///
+ /// OpenAPI specification files.
+ ///
public required List SpecFiles { get; init; }
///
- /// Whether this configuration has a custom template file.
+ /// Ordered list of Markdown files that appear after the last spec (outro content).
///
- public bool HasCustomTemplate => TemplateFile != null;
+ public List OutroMarkdownFiles { get; init; } = [];
+
+ ///
+ /// Whether this configuration has intro or outro markdown files.
+ ///
+ public bool HasSupplementaryContent => IntroMarkdownFiles.Count > 0 || OutroMarkdownFiles.Count > 0;
///
/// Primary specification file (first in the list, for backward compatibility).
///
public IFileInfo PrimarySpecFile => SpecFiles.First();
+
+ ///
+ /// Gets all Markdown file paths that should be excluded from normal HTML generation.
+ ///
+ public IEnumerable GetMarkdownPathsToExclude(string documentationSourceDirectoryFullName)
+ {
+ foreach (var file in IntroMarkdownFiles)
+ yield return Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/');
+ foreach (var file in OutroMarkdownFiles)
+ yield return Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/');
+ }
}
diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs
index 5b0892b8b..af6c63e94 100644
--- a/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs
+++ b/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs
@@ -11,17 +11,24 @@ namespace Elastic.Documentation.Configuration.Toc;
///
/// YAML converter that provides backward compatibility for API configuration.
-/// Supports both old string format and new object format.
+/// Supports legacy string/object formats and new sequence format.
///
-/// Old format: api: { elasticsearch: "elasticsearch-openapi.json" }
-/// New format: api: { elasticsearch: { spec: "elasticsearch-openapi.json", template: "elasticsearch-api-overview.md" } }
+/// Legacy string format: api: { elasticsearch: "elasticsearch-openapi.json" }
+/// Legacy object format: api: { elasticsearch: { spec: "elasticsearch-openapi.json" } }
+/// New sequence format: api: { kibana: [{ file: "intro.md" }, { spec: "kibana-openapi.json" }, { file: "outro.md" }] }
///
public class ApiConfigurationConverter : IYamlTypeConverter
{
- public bool Accepts(Type type) => type == typeof(ApiConfiguration);
+ public bool Accepts(Type type) => type == typeof(ApiConfiguration) || type == typeof(ApiProductSequence);
public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
{
+ if (type == typeof(ApiProductSequence))
+ {
+ return ReadApiProductSequence(parser, rootDeserializer);
+ }
+
+ // Legacy ApiConfiguration handling
if (parser.Current is Scalar scalar)
{
// Handle old string format: "elasticsearch-openapi.json"
@@ -31,7 +38,7 @@ public class ApiConfigurationConverter : IYamlTypeConverter
if (parser.Current is MappingStart)
{
- // Handle new object format: { spec: "...", template: "...", specs: [...] }
+ // Handle legacy object format: { spec: "...", template: "...", specs: [...] }
_ = parser.MoveNext();
var config = new ApiConfiguration();
@@ -53,30 +60,106 @@ public class ApiConfigurationConverter : IYamlTypeConverter
}
break;
case "template":
- if (parser.Current is Scalar templateValue)
+ // Legacy template support - skip for ApiConfiguration parsing
+ parser.SkipThisAndNestedEvents();
+ break;
+ default:
+ // Safely consume unknown values (including nested mappings/sequences)
+ parser.SkipThisAndNestedEvents();
+ break;
+ }
+ }
+
+ _ = parser.MoveNext(); // consume MappingEnd
+ return config;
+ }
+
+ throw new YamlException(parser.Current?.Start ?? Mark.Empty, parser.Current?.End ?? Mark.Empty,
+ "API configuration must be either a string (spec path) or an object with spec field");
+ }
+
+ private ApiProductSequence ReadApiProductSequence(IParser parser, ObjectDeserializer rootDeserializer)
+ {
+ if (parser.Current is Scalar scalar)
+ {
+ // Convert legacy string format to sequence: "elasticsearch-openapi.json"
+ _ = parser.MoveNext();
+ return new ApiProductSequence
+ {
+ Entries = [new ApiProductEntry { Spec = scalar.Value }]
+ };
+ }
+
+ if (parser.Current is MappingStart)
+ {
+ // Convert legacy object format to sequence: { spec: "..." }
+ _ = parser.MoveNext();
+ var entries = new List();
+ string? specPath = null;
+
+ while (parser.Current is not MappingEnd)
+ {
+ var key = parser.Consume();
+ switch (key.Value)
+ {
+ case "spec":
+ if (parser.Current is Scalar specValue)
{
- config.Template = templateValue.Value;
+ specPath = specValue.Value;
_ = parser.MoveNext();
}
else
{
- // Wrong token type - skip safely
parser.SkipThisAndNestedEvents();
}
break;
+ case "template":
+ // Legacy template support removed - skip entirely
+ parser.SkipThisAndNestedEvents();
+ break;
default:
- // Safely consume unknown values (including nested mappings/sequences)
parser.SkipThisAndNestedEvents();
break;
}
}
_ = parser.MoveNext(); // consume MappingEnd
- return config;
+
+ // Build sequence with spec only
+ if (!string.IsNullOrWhiteSpace(specPath))
+ {
+ entries.Add(new ApiProductEntry { Spec = specPath });
+ }
+
+ return new ApiProductSequence { Entries = entries };
+ }
+
+ if (parser.Current is SequenceStart)
+ {
+ // Handle new sequence format: [{ file: "intro.md" }, { spec: "kibana-openapi.json" }, { file: "outro.md" }]
+ _ = parser.MoveNext(); // consume SequenceStart
+ var entries = new List();
+
+ while (parser.Current is not SequenceEnd)
+ {
+ if (parser.Current is MappingStart)
+ {
+ var entry = (ApiProductEntry)rootDeserializer(typeof(ApiProductEntry))!;
+ entries.Add(entry);
+ }
+ else
+ {
+ // Skip unexpected tokens
+ parser.SkipThisAndNestedEvents();
+ }
+ }
+
+ _ = parser.MoveNext(); // consume SequenceEnd
+ return new ApiProductSequence { Entries = entries };
}
throw new YamlException(parser.Current?.Start ?? Mark.Empty, parser.Current?.End ?? Mark.Empty,
- "API configuration must be either a string (spec path) or an object with spec/template fields");
+ "API configuration must be either a string (spec path), an object with spec field, or a sequence of file/spec entries");
}
public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
@@ -86,5 +169,10 @@ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializ
// Always write as object format for consistency
serializer(config, typeof(ApiConfiguration));
}
+ else if (value is ApiProductSequence sequence)
+ {
+ // Write as sequence format
+ serializer(sequence, typeof(ApiProductSequence));
+ }
}
}
diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs
index c21280f70..98c092de4 100644
--- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs
+++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs
@@ -52,7 +52,7 @@ public class DocumentationSetFile : TableOfContentsFile
public DocumentationSetFeatures Features { get; set; } = new();
[YamlMember(Alias = "api")]
- public Dictionary Api { get; set; } = [];
+ public Dictionary Api { get; set; } = [];
///
/// Default products for this documentation set. These are merged with page-level frontmatter products.
diff --git a/src/Elastic.Documentation/IMarkdownStringRenderer.cs b/src/Elastic.Documentation/IMarkdownStringRenderer.cs
index 20e486d88..6efacc17f 100644
--- a/src/Elastic.Documentation/IMarkdownStringRenderer.cs
+++ b/src/Elastic.Documentation/IMarkdownStringRenderer.cs
@@ -9,6 +9,11 @@ namespace Elastic.Documentation;
public interface IMarkdownStringRenderer
{
string Render(string markdown, IFileInfo? source);
+
+ ///
+ /// Renders markdown without removing the first level-1 heading. strips it so the layout can render the title separately; API intro/outro pages keep the hash heading as the main title in HTML.
+ ///
+ string RenderPreservingFirstHeading(string markdown, IFileInfo? source) => Render(markdown, source);
}
public class NoopMarkdownStringRenderer : IMarkdownStringRenderer
{
diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs
index 159103a88..265b87d6b 100644
--- a/src/Elastic.Markdown/DocumentationGenerator.cs
+++ b/src/Elastic.Markdown/DocumentationGenerator.cs
@@ -294,6 +294,23 @@ private async Task ProcessFile(HashSet offendingFiles, DocumentationFile
}
_logger.LogTrace("--> {FileFullPath}", file.SourceFile.FullName);
+
+ // Skip normal HTML generation for intro/outro files that will be rendered via API pipeline
+ if (IsApiMarkdownFile(file.RelativePath))
+ {
+ _logger.LogTrace("Skipping HTML generation for API intro/outro file: {RelativePath}", file.RelativePath);
+
+ // Still allow Myst processing for cross-links and diagnostics, but skip HTML output
+ if (file is MarkdownFile markdown)
+ {
+ // Parse the markdown for cross-link resolution and diagnostics only
+ var document = await markdown.ParseFullAsync(DocumentationSet.TryFindDocumentByRelativePath, ctx);
+ // Cross-links and diagnostics are handled during parsing, so we're done
+ }
+
+ return;
+ }
+
var outputFile = OutputFile(file.RelativePath);
if (outputFile is not null)
@@ -440,4 +457,39 @@ public async Task RenderLayout(MarkdownFile markdown, Cancel ctx)
return await HtmlWriter.RenderLayout(markdown, ctx);
}
+ ///
+ /// Checks if a file path is registered as an intro/outro file in any API configuration.
+ /// These files should be rendered via the API pipeline rather than normal HTML generation.
+ ///
+ private bool IsApiMarkdownFile(string relativePath)
+ {
+ var normalized = relativePath.Replace(Path.DirectorySeparatorChar, '/');
+
+ if (Context.Configuration.ApiConfigurations == null)
+ return false;
+
+ foreach (var apiConfig in Context.Configuration.ApiConfigurations.Values)
+ {
+ // Check intro files
+ foreach (var introFile in apiConfig.IntroMarkdownFiles)
+ {
+ var introRelativePath = Path.GetRelativePath(Context.DocumentationSourceDirectory.FullName, introFile.FullName)
+ .Replace(Path.DirectorySeparatorChar, '/');
+ if (string.Equals(normalized, introRelativePath, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+
+ // Check outro files
+ foreach (var outroFile in apiConfig.OutroMarkdownFiles)
+ {
+ var outroRelativePath = Path.GetRelativePath(Context.DocumentationSourceDirectory.FullName, outroFile.FullName)
+ .Replace(Path.DirectorySeparatorChar, '/');
+ if (string.Equals(normalized, outroRelativePath, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
}
diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs
index 4f309b47f..cd1d29ccf 100644
--- a/src/Elastic.Markdown/HtmlWriter.cs
+++ b/src/Elastic.Markdown/HtmlWriter.cs
@@ -47,11 +47,18 @@ public class HtmlWriter(
private IPageViewFactory PageViewFactory { get; } = pageViewFactory ?? new DefaultPageViewFactory();
///
- public string Render(string markdown, IFileInfo? source)
+ public string Render(string markdown, IFileInfo? source) =>
+ RenderCore(markdown, source, stripFirstHeadingLevel1: true);
+
+ ///
+ public string RenderPreservingFirstHeading(string markdown, IFileInfo? source) =>
+ RenderCore(markdown, source, stripFirstHeadingLevel1: false);
+
+ private string RenderCore(string markdown, IFileInfo? source, bool stripFirstHeadingLevel1)
{
source ??= DocumentationSet.Context.ConfigurationPath;
var parsed = DocumentationSet.MarkdownParser.ParseStringAsync(markdown, source, null);
- return MarkdownFile.CreateHtml(parsed);
+ return MarkdownFile.CreateHtml(parsed, stripFirstHeadingLevel1);
}
public async Task RenderLayout(MarkdownFile markdown, Cancel ctx = default)
diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs
index ee548e9bb..bd6000009 100644
--- a/src/Elastic.Markdown/IO/MarkdownFile.cs
+++ b/src/Elastic.Markdown/IO/MarkdownFile.cs
@@ -435,12 +435,15 @@ private YamlFrontMatter ReadYamlFrontMatter(string raw)
}
}
- public static string CreateHtml(MarkdownDocument document)
+ public static string CreateHtml(MarkdownDocument document, bool stripFirstHeadingLevel1 = true)
{
- //we manually render title and optionally append an applies block embedded in yaml front matter.
- var h1 = document.Descendants().FirstOrDefault(h => h.Level == 1);
- if (h1 is not null)
- _ = document.Remove(h1);
+ // We manually render title and optionally append an applies block embedded in yaml front matter.
+ if (stripFirstHeadingLevel1)
+ {
+ var h1 = document.Descendants().FirstOrDefault(h => h.Level == 1);
+ if (h1 is not null)
+ _ = document.Remove(h1);
+ }
var html = document.ToHtml(MarkdownParser.Pipeline);
return InsertFootnotesHeading(html);
diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs
index 9d7d9674a..70acd4d75 100644
--- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs
+++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs
@@ -56,6 +56,10 @@ bool isWatchBuild
// Track OpenAPI spec file modification times to detect changes
private readonly Dictionary _openApiSpecLastModified = [];
+
+ // Track intro/outro markdown file modification times to detect changes
+ private readonly Dictionary _apiMarkdownFilesLastModified = [];
+
private volatile bool _apiReferencesStale = true;
private readonly SemaphoreSlim _apiSemaphore = new(1, 1);
private CancellationTokenSource? _apiGenerationCts;
@@ -122,20 +126,61 @@ private bool HaveOpenApiSpecsChanged(ConfigurationFile config)
{
if (_isWatchBuild)
return false;
- if (config.OpenApiSpecifications is null)
+ if (config.OpenApiSpecifications is null && config.ApiConfigurations is null)
return false;
// First run - no timestamps yet
- if (_openApiSpecLastModified.Count == 0)
+ if (_openApiSpecLastModified.Count == 0 && _apiMarkdownFilesLastModified.Count == 0)
return true;
- foreach (var (_, fileInfo) in config.OpenApiSpecifications)
+ // Check legacy OpenAPI specification files
+ if (config.OpenApiSpecifications is not null)
+ {
+ foreach (var (_, fileInfo) in config.OpenApiSpecifications)
+ {
+ fileInfo.Refresh();
+ if (!_openApiSpecLastModified.TryGetValue(fileInfo.FullName, out var lastModified))
+ return true; // New file
+ if (fileInfo.LastWriteTimeUtc > lastModified)
+ return true; // File modified
+ }
+ }
+
+ // Check new API configuration files (specs and intro/outro markdown files)
+ if (config.ApiConfigurations is not null)
{
- fileInfo.Refresh();
- if (!_openApiSpecLastModified.TryGetValue(fileInfo.FullName, out var lastModified))
- return true; // New file
- if (fileInfo.LastWriteTimeUtc > lastModified)
- return true; // File modified
+ foreach (var apiConfig in config.ApiConfigurations.Values)
+ {
+ // Check spec files
+ foreach (var specFile in apiConfig.SpecFiles)
+ {
+ specFile.Refresh();
+ if (!_openApiSpecLastModified.TryGetValue(specFile.FullName, out var lastModified))
+ return true; // New file
+ if (specFile.LastWriteTimeUtc > lastModified)
+ return true; // File modified
+ }
+
+ // Check intro markdown files
+ foreach (var markdownFile in apiConfig.IntroMarkdownFiles)
+ {
+ markdownFile.Refresh();
+ if (!_apiMarkdownFilesLastModified.TryGetValue(markdownFile.FullName, out var lastModified))
+ return true; // New file
+ if (markdownFile.LastWriteTimeUtc > lastModified)
+ return true; // File modified
+ }
+
+ // Check outro markdown files
+ foreach (var markdownFile in apiConfig.OutroMarkdownFiles)
+ {
+ markdownFile.Refresh();
+ if (!_apiMarkdownFilesLastModified.TryGetValue(markdownFile.FullName, out var lastModified))
+ return true; // New file
+ if (markdownFile.LastWriteTimeUtc > lastModified)
+ return true; // File modified
+ }
+ }
}
return false;
@@ -143,14 +188,45 @@ private bool HaveOpenApiSpecsChanged(ConfigurationFile config)
private void UpdateOpenApiSpecTimestamps(ConfigurationFile config)
{
- if (config.OpenApiSpecifications is null)
- return;
-
_openApiSpecLastModified.Clear();
- foreach (var (_, fileInfo) in config.OpenApiSpecifications)
+ _apiMarkdownFilesLastModified.Clear();
+
+ // Update legacy OpenAPI specification timestamps
+ if (config.OpenApiSpecifications is not null)
{
- fileInfo.Refresh();
- _openApiSpecLastModified[fileInfo.FullName] = fileInfo.LastWriteTimeUtc;
+ foreach (var (_, fileInfo) in config.OpenApiSpecifications)
+ {
+ fileInfo.Refresh();
+ _openApiSpecLastModified[fileInfo.FullName] = fileInfo.LastWriteTimeUtc;
+ }
+ }
+
+ // Update new API configuration timestamps (specs and intro/outro markdown files)
+ if (config.ApiConfigurations is not null)
+ {
+ foreach (var apiConfig in config.ApiConfigurations.Values)
+ {
+ // Update spec file timestamps
+ foreach (var specFile in apiConfig.SpecFiles)
+ {
+ specFile.Refresh();
+ _openApiSpecLastModified[specFile.FullName] = specFile.LastWriteTimeUtc;
+ }
+
+ // Update intro markdown file timestamps
+ foreach (var markdownFile in apiConfig.IntroMarkdownFiles)
+ {
+ markdownFile.Refresh();
+ _apiMarkdownFilesLastModified[markdownFile.FullName] = markdownFile.LastWriteTimeUtc;
+ }
+
+ // Update outro markdown file timestamps
+ foreach (var markdownFile in apiConfig.OutroMarkdownFiles)
+ {
+ markdownFile.Refresh();
+ _apiMarkdownFilesLastModified[markdownFile.FullName] = markdownFile.LastWriteTimeUtc;
+ }
+ }
}
}
diff --git a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs
new file mode 100644
index 000000000..4ba1c8772
--- /dev/null
+++ b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs
@@ -0,0 +1,118 @@
+// 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 AwesomeAssertions;
+using Elastic.ApiExplorer;
+using Elastic.ApiExplorer.Landing;
+using Elastic.Documentation;
+using Elastic.Documentation.Configuration;
+using Elastic.Documentation.Configuration.Toc;
+using Elastic.Documentation.Diagnostics;
+using Elastic.Documentation.Navigation;
+using Elastic.Documentation.Site.FileProviders;
+using Elastic.Documentation.Site.Navigation;
+using Microsoft.Extensions.Logging.Abstractions;
+using Nullean.ScopedFileSystem;
+
+namespace Elastic.ApiExplorer.Tests;
+
+///
+/// Tests for intro markdown pages in API Explorer navigation.
+///
+public class KibanaApiMarkdownNavigationTests
+{
+ private sealed class StubMarkdownRenderer : IMarkdownStringRenderer
+ {
+ public string Render(string markdown, IFileInfo? source) => "stub-body
";
+ public string RenderPreservingFirstHeading(string markdown, IFileInfo? source) =>
+ "Kibana spaces
stub-body
";
+ }
+
+ private static (LandingNavigationItem navigation, SimpleMarkdownNavigationItem introNav) SetupKibanaNavigation()
+ {
+ var root = Paths.WorkingDirectoryRoot.FullName;
+ var introPath = Path.Combine(root, "docs", "kibana-api-overview.md");
+ var specPath = Path.Combine(root, "docs", "kibana-openapi.json");
+ var fs = new FileSystem();
+ var introFile = fs.FileInfo.New(introPath);
+ var specFile = fs.FileInfo.New(specPath);
+
+ var apiConfig = new ResolvedApiConfiguration
+ {
+ ProductKey = "kibana",
+ IntroMarkdownFiles = [introFile],
+ SpecFiles = [specFile]
+ };
+
+ var collector = new DiagnosticsCollector([]);
+ var configurationContext = TestHelpers.CreateConfigurationContext(fs);
+ var context = new BuildContext(collector, FileSystemFactory.RealRead, configurationContext);
+ var doc = OpenApiReader.Create(specFile).GetAwaiter().GetResult();
+ doc.Should().NotBeNull("OpenAPI document should load successfully");
+ var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance);
+ var navigation = generator.CreateNavigation("kibana", doc, apiConfig);
+ var introNav = navigation.NavigationItems.OfType().First();
+
+ return (navigation, introNav);
+ }
+
+ [Fact]
+ public void IntroNav_ShouldBeLeafNavigationItem()
+ {
+ var (_, introNav) = SetupKibanaNavigation();
+
+ introNav.Should().BeAssignableTo>();
+ introNav.NavigationTitle.Should().Be("Spaces");
+ introNav.Slug.Should().Be("kibana-api-overview");
+ }
+
+ [Fact]
+ public void IntroNav_ShouldAppearFirstInNavigation()
+ {
+ var (navigation, introNav) = SetupKibanaNavigation();
+
+ var firstItem = navigation.NavigationItems.First();
+ firstItem.Should().Be(introNav);
+ firstItem.Should().BeOfType();
+ }
+
+ [Fact]
+ public void IntroNav_ShouldGenerateCorrectUrl()
+ {
+ var (_, introNav) = SetupKibanaNavigation();
+
+ introNav.Url.Should().Be("/api/kibana/kibana-api-overview/");
+ }
+
+ [Fact]
+ public void UrlCollisionValidation_ShouldDetectReservedSegments()
+ {
+ var actTypes = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions("types", "kibana", "/docs/types.md");
+ var actTags = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions("tags", "kibana", "/docs/tags.md");
+
+ actTypes.Should().Throw().WithMessage("*conflicts with reserved API Explorer segment*types*");
+ actTags.Should().Throw().WithMessage("*conflicts with reserved API Explorer segment*tags*");
+ }
+
+ [Fact]
+ public void UrlCollisionValidation_ShouldDetectOperationMonikers()
+ {
+ var operationMonikers = new HashSet(StringComparer.OrdinalIgnoreCase) { "search", "index" };
+
+ var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions("search", "kibana", "/docs/search.md", operationMonikers);
+
+ act.Should().Throw().WithMessage("*conflicts with existing operation moniker*");
+ }
+
+ [Fact]
+ public void UrlCollisionValidation_ShouldAllowValidSlugs()
+ {
+ var operationMonikers = new HashSet(StringComparer.OrdinalIgnoreCase) { "search", "index" };
+
+ var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions("overview", "kibana", "/docs/overview.md", operationMonikers);
+
+ act.Should().NotThrow();
+ }
+}
diff --git a/tests/Elastic.ApiExplorer.Tests/SimpleMarkdownNavigationItemTests.cs b/tests/Elastic.ApiExplorer.Tests/SimpleMarkdownNavigationItemTests.cs
new file mode 100644
index 000000000..a8a462566
--- /dev/null
+++ b/tests/Elastic.ApiExplorer.Tests/SimpleMarkdownNavigationItemTests.cs
@@ -0,0 +1,74 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions.TestingHelpers;
+using AwesomeAssertions;
+
+namespace Elastic.ApiExplorer.Tests;
+
+public class SimpleMarkdownNavigationItemTests
+{
+ [Theory]
+ [InlineData("intro.md", "intro")]
+ [InlineData("getting-started.md", "getting-started")]
+ [InlineData("getting_started.md", "getting-started")]
+ [InlineData("Getting Started.md", "getting-started")]
+ [InlineData("API_Overview.md", "api-overview")]
+ public void CreateSlugFromFile_GeneratesCorrectSlug(string fileName, string expectedSlug)
+ {
+ var fileSystem = new MockFileSystem();
+ var file = fileSystem.FileInfo.New($"/docs/{fileName}");
+
+ var slug = SimpleMarkdownNavigationItem.CreateSlugFromFile(file);
+
+ slug.Should().Be(expectedSlug);
+ }
+
+ [Theory]
+ [InlineData("types", "types")]
+ [InlineData("tags", "tags")]
+ public void ValidateSlugForCollisions_ThrowsForReservedSegments(string slug, string reservedSegment)
+ {
+ var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions(
+ slug, "elasticsearch", "/docs/file.md");
+
+ act.Should().Throw()
+ .WithMessage($"*conflicts with reserved API Explorer segment*{reservedSegment}*");
+ }
+
+ [Fact]
+ public void ValidateSlugForCollisions_ThrowsForOperationMoniker()
+ {
+ var operationMonikers = new HashSet(StringComparer.OrdinalIgnoreCase) { "search", "index", "get" };
+
+ var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions(
+ "search", "elasticsearch", "/docs/search.md", operationMonikers);
+
+ act.Should().Throw()
+ .WithMessage("*conflicts with existing operation moniker*");
+ }
+
+ [Fact]
+ public void ValidateSlugForCollisions_AllowsValidSlug()
+ {
+ var operationMonikers = new HashSet(StringComparer.OrdinalIgnoreCase) { "search", "index", "get" };
+
+ var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions(
+ "overview", "elasticsearch", "/docs/overview.md", operationMonikers);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void ValidateSlugForCollisions_IsCaseInsensitive()
+ {
+ var operationMonikers = new HashSet(StringComparer.OrdinalIgnoreCase) { "search", "index", "get" };
+
+ var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions(
+ "SEARCH", "elasticsearch", "/docs/search.md", operationMonikers);
+
+ act.Should().Throw()
+ .WithMessage("*conflicts with existing operation moniker*");
+ }
+}
diff --git a/tests/Elastic.ApiExplorer.Tests/TemplateProcessorTests.cs b/tests/Elastic.ApiExplorer.Tests/TemplateProcessorTests.cs
deleted file mode 100644
index 4a74f3ed9..000000000
--- a/tests/Elastic.ApiExplorer.Tests/TemplateProcessorTests.cs
+++ /dev/null
@@ -1,111 +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.IO.Abstractions.TestingHelpers;
-using AwesomeAssertions;
-using Elastic.ApiExplorer.Templates;
-using Elastic.Documentation;
-using Elastic.Documentation.Configuration.Toc;
-using FakeItEasy;
-
-namespace Elastic.ApiExplorer.Tests;
-
-public class TemplateProcessorTests
-{
- private readonly IMarkdownStringRenderer _mockRenderer;
- private readonly TemplateProcessor _processor;
- private readonly MockFileSystem _fileSystem;
-
- public TemplateProcessorTests()
- {
- _mockRenderer = A.Fake();
- _fileSystem = new MockFileSystem();
- _processor = new TemplateProcessor(_mockRenderer, _fileSystem);
- }
-
- [Fact]
- public async Task ProcessTemplateAsync_WithCustomTemplate_ProcessesContent()
- {
- // Arrange
- var templateContent = "# API Overview\n\nThis is a custom template.";
- var expectedHtml = "API Overview
This is a custom template.
";
-
- var templateFile = _fileSystem.FileInfo.New("/path/to/template.md");
- _fileSystem.AddFile(templateFile.FullName, new MockFileData(templateContent));
-
- var apiConfig = new ResolvedApiConfiguration
- {
- ProductKey = "kibana",
- TemplateFile = templateFile,
- SpecFiles = [_fileSystem.FileInfo.New("/path/to/spec.json")]
- };
-
- A.CallTo(() => _mockRenderer.Render(templateContent, templateFile))
- .Returns(expectedHtml);
-
- // Act
- var result = await _processor.ProcessTemplateAsync(apiConfig, TestContext.Current.CancellationToken);
-
- // Assert
- result.Should().Be(expectedHtml);
- A.CallTo(() => _mockRenderer.Render(templateContent, templateFile)).MustHaveHappenedOnceExactly();
- }
-
- [Fact]
- public async Task ProcessTemplateAsync_WithoutCustomTemplate_ReturnsEmpty()
- {
- // Arrange
- var apiConfig = new ResolvedApiConfiguration
- {
- ProductKey = "kibana",
- TemplateFile = null,
- SpecFiles = [_fileSystem.FileInfo.New("/path/to/spec.json")]
- };
-
- // Act
- var result = await _processor.ProcessTemplateAsync(apiConfig, TestContext.Current.CancellationToken);
-
- // Assert
- result.Should().Be(string.Empty);
- A.CallTo(() => _mockRenderer.Render(A._, A._)).MustNotHaveHappened();
- }
-
- [Fact]
- public async Task ProcessTemplateAsync_WithCancellation_ThrowsOperationCancelledException()
- {
- // Arrange
- var templateFile = _fileSystem.FileInfo.New("/path/to/template.md");
- _fileSystem.AddFile(templateFile.FullName, new MockFileData("# Test"));
-
- var apiConfig = new ResolvedApiConfiguration
- {
- ProductKey = "kibana",
- TemplateFile = templateFile,
- SpecFiles = [_fileSystem.FileInfo.New("/path/to/spec.json")]
- };
-
- using var cts = new CancellationTokenSource();
- cts.Cancel();
-
- // Act & Assert
- var act = () => _processor.ProcessTemplateAsync(apiConfig, cts.Token);
- await act.Should().ThrowAsync();
- }
-
- [Fact]
- public void TemplateProcessorFactory_Create_ReturnsTemplateProcessor()
- {
- // Arrange
- var mockRenderer = A.Fake();
- var mockFileSystem = new MockFileSystem();
-
- // Act
- var processor = TemplateProcessorFactory.Create(mockRenderer, mockFileSystem);
-
- // Assert
- processor.Should().NotBeNull();
- processor.Should().BeOfType();
- }
-}
diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs
index 4098c46dd..f9c9a6ddf 100644
--- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs
+++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs
@@ -38,18 +38,6 @@ public void ApiConfiguration_InvalidWhenNoSpecs()
}
- [Fact]
- public void ApiConfiguration_WithTemplate()
- {
- var config = new ApiConfiguration
- {
- Spec = "elasticsearch-openapi.json",
- Template = "elasticsearch-api-overview.md"
- };
-
- config.IsValid.Should().BeTrue();
- config.Template.Should().Be("elasticsearch-api-overview.md");
- }
[Fact]
public void ApiConfiguration_InvalidWhenSpecIsEmpty()
@@ -69,6 +57,109 @@ public void ApiConfiguration_InvalidWhenSpecIsWhitespace()
}
+public class ApiProductSequenceTests
+{
+ [Fact]
+ public void ApiProductSequence_ValidatesRequiresAtLeastOneSpec()
+ {
+ var sequence = new ApiProductSequence
+ {
+ Entries = [new ApiProductEntry { File = "intro.md" }]
+ };
+
+ sequence.IsValid.Should().BeFalse();
+ }
+
+ [Fact]
+ public void ApiProductSequence_ValidWithSpecOnly()
+ {
+ var sequence = new ApiProductSequence
+ {
+ Entries = [new ApiProductEntry { Spec = "api.json" }]
+ };
+
+ sequence.IsValid.Should().BeTrue();
+ sequence.GetSpecPaths().Should().BeEquivalentTo(["api.json"]);
+ sequence.GetIntroMarkdownFiles().Should().BeEmpty();
+ sequence.GetOutroMarkdownFiles().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ApiProductSequence_ValidWithIntroSpecOutro()
+ {
+ var sequence = new ApiProductSequence
+ {
+ Entries = [
+ new ApiProductEntry { File = "intro.md" },
+ new ApiProductEntry { Spec = "api.json" },
+ new ApiProductEntry { File = "outro.md" }
+ ]
+ };
+
+ sequence.IsValid.Should().BeTrue();
+ sequence.GetSpecPaths().Should().BeEquivalentTo(["api.json"]);
+ sequence.GetIntroMarkdownFiles().Should().BeEquivalentTo(["intro.md"]);
+ sequence.GetOutroMarkdownFiles().Should().BeEquivalentTo(["outro.md"]);
+ }
+
+ [Fact]
+ public void ApiProductSequence_InvalidWhenMultipleSpecs()
+ {
+ var sequence = new ApiProductSequence
+ {
+ Entries = [
+ new ApiProductEntry { File = "intro.md" },
+ new ApiProductEntry { Spec = "api1.json" },
+ new ApiProductEntry { Spec = "api2.json" },
+ new ApiProductEntry { File = "outro.md" }
+ ]
+ };
+
+ sequence.IsValid.Should().BeFalse(); // Invalid due to multiple specs
+ }
+
+ [Fact]
+ public void ApiProductSequence_SeparatesIntroAndOutroFiles()
+ {
+ var sequence = new ApiProductSequence
+ {
+ Entries = [
+ new ApiProductEntry { File = "intro1.md" },
+ new ApiProductEntry { File = "intro2.md" },
+ new ApiProductEntry { Spec = "api1.json" },
+ new ApiProductEntry { Spec = "api2.json" },
+ new ApiProductEntry { File = "outro1.md" },
+ new ApiProductEntry { File = "outro2.md" }
+ ]
+ };
+
+ sequence.IsValid.Should().BeFalse(); // Invalid due to multiple specs
+ sequence.GetIntroMarkdownFiles().Should().BeEquivalentTo(["intro1.md", "intro2.md"]);
+ sequence.GetSpecPaths().Should().BeEquivalentTo(["api1.json", "api2.json"]);
+ sequence.GetOutroMarkdownFiles().Should().BeEquivalentTo(["outro1.md", "outro2.md"]);
+ }
+
+ [Fact]
+ public void ApiProductEntry_ValidatesExactlyOneProperty()
+ {
+ var validFile = new ApiProductEntry { File = "test.md" };
+ var validSpec = new ApiProductEntry { Spec = "test.json" };
+ var invalidBoth = new ApiProductEntry { File = "test.md", Spec = "test.json" };
+ var invalidNone = new ApiProductEntry();
+
+ validFile.IsValid.Should().BeTrue();
+ validFile.IsMarkdownFile.Should().BeTrue();
+ validFile.IsOpenApiSpec.Should().BeFalse();
+
+ validSpec.IsValid.Should().BeTrue();
+ validSpec.IsMarkdownFile.Should().BeFalse();
+ validSpec.IsOpenApiSpec.Should().BeTrue();
+
+ invalidBoth.IsValid.Should().BeFalse();
+ invalidNone.IsValid.Should().BeFalse();
+ }
+}
+
public class ApiConfigurationConverterTests
{
private readonly IDeserializer _deserializer;
@@ -86,12 +177,11 @@ public void Converter_HandlesStringFormat()
config.Should().NotBeNull();
config.Spec.Should().Be("elasticsearch-openapi.json");
- config.Template.Should().BeNull();
config.Specs.Should().BeNull();
}
[Fact]
- public void Converter_HandlesObjectFormat()
+ public void Converter_HandlesObjectFormat_IgnoresTemplate()
{
const string yaml = """
spec: elasticsearch-openapi.json
@@ -102,7 +192,6 @@ public void Converter_HandlesObjectFormat()
config.Should().NotBeNull();
config.Spec.Should().Be("elasticsearch-openapi.json");
- config.Template.Should().Be("elasticsearch-api-overview.md");
config.Specs.Should().BeNull();
}
@@ -123,7 +212,6 @@ public void Converter_SkipsUnknownPropertiesWithNestedContent()
config.Should().NotBeNull();
config.Spec.Should().Be("elasticsearch-openapi.json");
- config.Template.Should().Be("elasticsearch-api-overview.md");
config.Specs.Should().BeNull();
}
@@ -140,12 +228,11 @@ public void Converter_HandlesWrongTokenTypeForSpec()
config.Should().NotBeNull();
config.Spec.Should().BeNull(); // Should be null when wrong token type
- config.Template.Should().Be("elasticsearch-api-overview.md");
config.Specs.Should().BeNull();
}
[Fact]
- public void Converter_HandlesWrongTokenTypeForTemplate()
+ public void Converter_IgnoresTemplateWithWrongTokenType()
{
const string yaml = """
spec: elasticsearch-openapi.json
@@ -158,7 +245,6 @@ public void Converter_HandlesWrongTokenTypeForTemplate()
config.Should().NotBeNull();
config.Spec.Should().Be("elasticsearch-openapi.json");
- config.Template.Should().BeNull(); // Should be null when wrong token type
config.Specs.Should().BeNull();
}
@@ -177,7 +263,6 @@ public void Converter_IgnoresSpecsPropertyForNow()
config.Should().NotBeNull();
config.Spec.Should().Be("elasticsearch-openapi.json");
- config.Template.Should().Be("elasticsearch-api-overview.md");
// Specs should remain null since multi-spec support is deferred
config.Specs.Should().BeNull();
}
@@ -203,25 +288,105 @@ public void Converter_HandlesComplexUnknownStructures()
config.Should().NotBeNull();
config.Spec.Should().Be("elasticsearch-openapi.json");
- config.Template.Should().Be("elasticsearch-api-overview.md");
config.Specs.Should().BeNull();
}
}
+public class ApiProductSequenceConverterTests
+{
+ private readonly IDeserializer _deserializer;
+
+ public ApiProductSequenceConverterTests() => _deserializer = new DeserializerBuilder()
+ .WithTypeConverter(new ApiConfigurationConverter())
+ .Build();
+
+ [Fact]
+ public void Converter_HandlesSequenceFormat()
+ {
+ const string yaml = """
+ - file: intro.md
+ - spec: api.json
+ - file: outro.md
+ """;
+
+ var sequence = _deserializer.Deserialize(yaml);
+
+ sequence.Should().NotBeNull();
+ sequence.IsValid.Should().BeTrue();
+ sequence.Entries.Should().HaveCount(3);
+ sequence.GetIntroMarkdownFiles().Should().BeEquivalentTo(["intro.md"]);
+ sequence.GetSpecPaths().Should().BeEquivalentTo(["api.json"]);
+ sequence.GetOutroMarkdownFiles().Should().BeEquivalentTo(["outro.md"]);
+ }
+
+ [Fact]
+ public void Converter_ConvertLegacyStringToSequence()
+ {
+ const string yaml = "api.json";
+
+ var sequence = _deserializer.Deserialize(yaml);
+
+ sequence.Should().NotBeNull();
+ sequence.IsValid.Should().BeTrue();
+ sequence.Entries.Should().HaveCount(1);
+ sequence.GetSpecPaths().Should().BeEquivalentTo(["api.json"]);
+ sequence.GetIntroMarkdownFiles().Should().BeEmpty();
+ sequence.GetOutroMarkdownFiles().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Converter_ConvertLegacyObjectToSequence_IgnoresTemplate()
+ {
+ const string yaml = """
+ spec: api.json
+ template: template.md
+ """;
+
+ var sequence = _deserializer.Deserialize(yaml);
+
+ sequence.Should().NotBeNull();
+ sequence.IsValid.Should().BeTrue();
+ sequence.Entries.Should().HaveCount(1);
+ sequence.GetIntroMarkdownFiles().Should().BeEmpty();
+ sequence.GetSpecPaths().Should().BeEquivalentTo(["api.json"]);
+ sequence.GetOutroMarkdownFiles().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Converter_ConvertLegacyObjectWithoutTemplateToSequence()
+ {
+ const string yaml = """
+ spec: api.json
+ """;
+
+ var sequence = _deserializer.Deserialize(yaml);
+
+ sequence.Should().NotBeNull();
+ sequence.IsValid.Should().BeTrue();
+ sequence.Entries.Should().HaveCount(1);
+ sequence.GetSpecPaths().Should().BeEquivalentTo(["api.json"]);
+ sequence.GetIntroMarkdownFiles().Should().BeEmpty();
+ sequence.GetOutroMarkdownFiles().Should().BeEmpty();
+ }
+}
+
public class ConfigurationFileApiTests
{
[Fact]
- public void ConfigurationFile_ProcessesNewApiConfiguration()
+ public void ConfigurationFile_ProcessesNewApiSequenceConfiguration()
{
// Arrange
var docSetFile = new DocumentationSetFile
{
- Api = new Dictionary
+ Api = new Dictionary
{
["elasticsearch"] = new()
{
- Spec = "elasticsearch-openapi.json",
- Template = "elasticsearch-api-overview.md"
+ Entries = [
+ new ApiProductEntry { File = "intro.md" },
+ new ApiProductEntry { Spec = "elasticsearch-openapi.json" },
+ new ApiProductEntry { File = "outro.md" }
+ ]
}
}
};
@@ -230,18 +395,20 @@ public void ConfigurationFile_ProcessesNewApiConfiguration()
// Assert
config.ApiConfigurations.Should().NotBeNull();
- config.ApiConfigurations!.Should().ContainKey("elasticsearch");
+ config.ApiConfigurations.Should().ContainKey("elasticsearch");
var elasticConfig = config.ApiConfigurations["elasticsearch"];
elasticConfig.ProductKey.Should().Be("elasticsearch");
- elasticConfig.HasCustomTemplate.Should().BeTrue();
- elasticConfig.TemplateFile!.Name.Should().Be("elasticsearch-api-overview.md");
+ elasticConfig.IntroMarkdownFiles.Should().HaveCount(1);
+ elasticConfig.IntroMarkdownFiles[0].Name.Should().Be("intro.md");
elasticConfig.SpecFiles.Should().HaveCount(1);
elasticConfig.PrimarySpecFile.Name.Should().Be("elasticsearch-openapi.json");
+ elasticConfig.OutroMarkdownFiles.Should().HaveCount(1);
+ elasticConfig.OutroMarkdownFiles[0].Name.Should().Be("outro.md");
// Backward compatibility
config.OpenApiSpecifications.Should().NotBeNull();
- config.OpenApiSpecifications!.Should().ContainKey("elasticsearch");
+ config.OpenApiSpecifications.Should().ContainKey("elasticsearch");
config.OpenApiSpecifications["elasticsearch"].Name.Should().Be("elasticsearch-openapi.json");
}
@@ -254,7 +421,9 @@ private static ConfigurationFile CreateConfiguration(DocumentationSetFile docSet
{
{ configFilePath, new MockFileData("") },
{ Path.Join(root, "docs", "elasticsearch-openapi.json"), new MockFileData("{}") },
- { Path.Join(root, "docs", "elasticsearch-api-overview.md"), new MockFileData("# Elasticsearch APIs") }
+ { Path.Join(root, "docs", "elasticsearch-api-overview.md"), new MockFileData("# Elasticsearch APIs") },
+ { Path.Join(root, "docs", "intro.md"), new MockFileData("# Introduction") },
+ { Path.Join(root, "docs", "outro.md"), new MockFileData("# Conclusion") }
}, root);
var configPath = fileSystem.FileInfo.New(configFilePath);
diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs
index 7971b2e67..30456cd5d 100644
--- a/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs
+++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs
@@ -97,8 +97,8 @@ public void DeserializesApiConfiguration()
var result = Deserialize(yaml);
result.Api.Should().HaveCount(2)
- .And.ContainKey("elasticsearch").WhoseValue.Spec.Should().Be("elasticsearch-openapi.json");
- result.Api.Should().ContainKey("kibana").WhoseValue.Spec.Should().Be("kibana-openapi.json");
+ .And.ContainKey("elasticsearch").WhoseValue.GetSpecPaths().Should().Contain("elasticsearch-openapi.json");
+ result.Api.Should().ContainKey("kibana").WhoseValue.GetSpecPaths().Should().Contain("kibana-openapi.json");
}
[Fact]
diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs
index 99c0606f2..0a392e842 100644
--- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs
+++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs
@@ -29,7 +29,7 @@ public void PhysicalDocsetFileCanBeDeserialized()
docSet.CrossLinks.Should().ContainSingle().Which.Should().Be("docs-content");
// Assert exclude patterns
- docSet.Exclude.Should().HaveCount(3).And.Subject.First().Should().Be("_*.md");
+ docSet.Exclude.Should().HaveCount(2).And.Subject.First().Should().Be("_*.md");
// Assert substitutions
docSet.Subs.Should().NotBeEmpty();
@@ -37,10 +37,9 @@ public void PhysicalDocsetFileCanBeDeserialized()
// Assert API configuration
docSet.Api.Should().HaveCount(3);
- docSet.Api.Should().ContainKey("elasticsearch").WhoseValue.Spec.Should().Be("elasticsearch-openapi.json");
- docSet.Api.Should().ContainKey("kibana").WhoseValue.Spec.Should().Be("kibana-openapi.json");
- docSet.Api["kibana"].Template.Should().Be("kibana-api-overview.md");
- docSet.Api.Should().ContainKey("dashboard").WhoseValue.Spec.Should().Be("dashboard-openapi.json");
+ docSet.Api.Should().ContainKey("elasticsearch").WhoseValue.GetSpecPaths().Should().Contain("elasticsearch-openapi.json");
+ docSet.Api.Should().ContainKey("kibana").WhoseValue.GetSpecPaths().Should().Contain("kibana-openapi.json");
+ docSet.Api.Should().ContainKey("dashboard").WhoseValue.GetSpecPaths().Should().Contain("dashboard-openapi.json");
// Assert TOC structure
docSet.TableOfContents.Should().NotBeEmpty();