From ad4e35b08a8458890907719bbed9117274b6d536 Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 17 Apr 2026 17:40:26 -0700 Subject: [PATCH 1/3] [API Explorer] Add intro and outro pages --- docs/_docset.yml | 6 +- docs/configure/content-set/api-explorer.md | 55 ++- docs/configure/content-set/navigation.md | 23 +- docs/kibana-api-overview.md | 9 +- src/Elastic.ApiExplorer/ApiRenderContext.cs | 2 - src/Elastic.ApiExplorer/ApiViewModel.cs | 12 +- .../Landing/LandingView.cshtml | 8 + .../Landing/MarkdownPageView.cshtml | 10 + .../Landing/MarkdownPageViewModel.cs | 20 ++ .../Landing/TemplateLanding.cs | 39 --- .../Landing/TemplateLandingView.cshtml | 21 -- .../Landing/TemplateLandingViewModel.cs | 28 -- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 158 +++++++-- .../SimpleMarkdownNavigationItem.cs | 79 +++++ .../Templates/TemplateProcessor.cs | 52 --- .../Builder/ConfigurationFile.cs | 51 ++- .../Toc/ApiConfiguration.cs | 142 +++++++- .../Toc/ApiConfigurationConverter.cs | 110 +++++- .../Toc/DocumentationSetFile.cs | 2 +- .../IMarkdownStringRenderer.cs | 5 + .../DocumentationGenerator.cs | 50 +++ src/Elastic.Markdown/HtmlWriter.cs | 11 +- src/Elastic.Markdown/IO/MarkdownFile.cs | 13 +- .../Http/ReloadableGeneratorState.cs | 104 +++++- .../KibanaApiMarkdownNavigationTests.cs | 321 ++++++++++++++++++ .../SimpleMarkdownNavigationItemTests.cs | 74 ++++ .../TemplateProcessorTests.cs | 111 ------ .../ApiConfigurationTests.cs | 213 ++++++++++-- .../DocumentationSetFileTests.cs | 4 +- .../PhysicalDocsetTests.cs | 9 +- 30 files changed, 1344 insertions(+), 398 deletions(-) create mode 100644 src/Elastic.ApiExplorer/Landing/MarkdownPageView.cshtml create mode 100644 src/Elastic.ApiExplorer/Landing/MarkdownPageViewModel.cs delete mode 100644 src/Elastic.ApiExplorer/Landing/TemplateLanding.cs delete mode 100644 src/Elastic.ApiExplorer/Landing/TemplateLandingView.cshtml delete mode 100644 src/Elastic.ApiExplorer/Landing/TemplateLandingViewModel.cs create mode 100644 src/Elastic.ApiExplorer/SimpleMarkdownNavigationItem.cs delete mode 100644 src/Elastic.ApiExplorer/Templates/TemplateProcessor.cs create mode 100644 tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs create mode 100644 tests/Elastic.ApiExplorer.Tests/SimpleMarkdownNavigationItemTests.cs delete mode 100644 tests/Elastic.ApiExplorer.Tests/TemplateProcessorTests.cs diff --git a/docs/_docset.yml b/docs/_docset.yml index 16e604fe9a..58702dbc43 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -8,8 +8,6 @@ cross_links: exclude: - '_*.md' - '!_search.md' - # API landing templates (see api.*.template): not standalone TOC pages; including them causes "Could not find … in navigation" during serve/build. - - '*-api-overview.md' subs: a-global-variable: "This was defined in docset.yml" serverless-short: Serverless @@ -51,8 +49,8 @@ suppress: api: elasticsearch: elasticsearch-openapi.json kibana: - spec: kibana-openapi.json - template: kibana-api-overview.md + - file: kibana-api-overview.md + - spec: kibana-openapi.json dashboard: dashboard-openapi.json toc: diff --git a/docs/configure/content-set/api-explorer.md b/docs/configure/content-set/api-explorer.md index 264fb203dc..a2d3a8f427 100644 --- a/docs/configure/content-set/api-explorer.md +++ b/docs/configure/content-set/api-explorer.md @@ -27,37 +27,60 @@ Each product key produces its own section of API documentation. For example, `el The `api` key is only valid in `docset.yml`. You can't use it in `toc.yml` files. -### Advanced configuration with templates +### Advanced configuration with intro and outro pages -When you specify a `template` file, `docs-builder` uses your custom Markdown file as the API landing page instead of generating an automatic overview: +You can add custom Markdown content before and after the auto-generated API documentation using a sequence format: ```yaml api: kibana: - spec: kibana-openapi.json - template: kibana-api-overview.md + - file: kibana-intro.md + - spec: kibana-openapi.json + - file: kibana-additional-notes.md ``` -Template files: +This configuration creates a navigation structure where: -- Must be Markdown files with `.md` extension -- Can use standard Markdown, substitutions +1. **Intro pages** (before the first `spec`) appear at the top of the sidebar +2. **Generated API content** (operations, tags, types) appears in the middle +3. **Outro pages** (after the spec) appear at the bottom -:::{note} -They must be explicitly excluded if they are not used in your table of contents. -Otherwise `docs-builder` treats them like normal pages and navigation can fail at build or serve time. -Add a glob (or explicit paths) under `exclude:` in `docset.yml` that matches your template filenames. -For example, exclude `*-api-overview.md`. -::: +#### Intro and outro page features: -#### Template example +- **Full Myst support**: Intro/outro pages support the full range of Myst Markdown features including cross-links, substitutions, and directives +- **Automatic exclusion**: No need to add intro/outro files to the `exclude:` list - they're automatically excluded from normal HTML generation +- **URL collision detection**: Build fails if intro/outro page names conflict with reserved API Explorer segments (`types/`, `tags/`) or operation names -Here's a sample template file (`kibana-api-overview.md`): +#### Multiple intro/outro pages + +You can include multiple intro and outro pages: + +```yaml +api: + kibana: + - file: introduction.md + - file: getting-started.md + - spec: kibana-openapi.json + - file: examples.md + - file: troubleshooting.md +``` + +#### Sample intro page + +Here's a sample intro page (`kibana-intro.md`): ```markdown # Kibana APIs -Welcome to the Kibana API documentation. +Welcome to the Kibana API documentation. These APIs allow you to manage Kibana programmatically. + +## Before you begin + +Make sure you have: + +- A running Kibana instance +- Valid authentication credentials +- Understanding of [RESTful API principles]({# TODO: link #}) ``` ## Place your spec files diff --git a/docs/configure/content-set/navigation.md b/docs/configure/content-set/navigation.md index 56266cbe34..e39933e732 100644 --- a/docs/configure/content-set/navigation.md +++ b/docs/configure/content-set/navigation.md @@ -96,7 +96,9 @@ exclude: ### `api` -Maps product names to OpenAPI specification files to enable the [API Explorer](api-explorer.md). Paths are relative to the folder containing `docset.yml`. Only valid in `docset.yml`, not in `toc.yml` files. +Configures API Explorer sections for your content set using OpenAPI specification files and optional intro/outro pages. Paths are relative to the folder containing `docset.yml`. Only valid in `docset.yml`, not in `toc.yml` files. + +#### Basic configuration ```yaml api: @@ -104,9 +106,24 @@ api: kibana: kibana-openapi.json ``` -Each key becomes a sub-path of the generated API docs: `elasticsearch` → `/api/elasticsearch/`, `kibana` → `/api/kibana/`. +#### Advanced configuration with intro/outro pages + +```yaml +api: + kibana: + - file: kibana-intro.md + - spec: kibana-openapi.json + - file: kibana-outro.md +``` + +Each product key becomes a sub-path of the generated API docs: `elasticsearch` → `/api/elasticsearch/`, `kibana` → `/api/kibana/`. + +The sequence format allows you to add custom Markdown content: +- **Intro pages** (before the first `spec`) appear at the top of the API navigation +- **Generated content** (operations, tags, types) appears in the middle +- **Outro pages** (after specs) appear at the bottom -See [API Explorer](api-explorer.md) for full configuration details. +See [API Explorer](api-explorer.md) for full configuration details and examples. ### `toc` diff --git a/docs/kibana-api-overview.md b/docs/kibana-api-overview.md index d9b9eb0706..2c041f644f 100644 --- a/docs/kibana-api-overview.md +++ b/docs/kibana-api-overview.md @@ -1,8 +1,7 @@ -# Kibana APIs - -Welcome to the Kibana API documentation. This page provides an overview of the available Kibana APIs. - -## Kibana spaces +--- +navigation_title: Spaces +--- +# Kibana spaces Spaces enable you to organize your dashboards and other saved objects into meaningful categories. You can use the default space or create your own spaces. diff --git a/src/Elastic.ApiExplorer/ApiRenderContext.cs b/src/Elastic.ApiExplorer/ApiRenderContext.cs index 87291017c4..2779aa3030 100644 --- a/src/Elastic.ApiExplorer/ApiRenderContext.cs +++ b/src/Elastic.ApiExplorer/ApiRenderContext.cs @@ -23,6 +23,4 @@ StaticFileContentHashProvider StaticFileContentHashProvider public required INavigationItem CurrentNavigation { get; init; } public required IMarkdownStringRenderer MarkdownRenderer { get; init; } - /// When set, the API root index uses this model instead of . - public TemplateLanding? TemplateLandingPage { get; init; } } diff --git a/src/Elastic.ApiExplorer/ApiViewModel.cs b/src/Elastic.ApiExplorer/ApiViewModel.cs index 41d072acbf..558d9f606a 100644 --- a/src/Elastic.ApiExplorer/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/ApiViewModel.cs @@ -49,6 +49,9 @@ public HtmlString RenderMarkdown(string? markdown) protected virtual IReadOnlyList GetTocItems() => []; + /// When set, drives for this page (e.g. intro/outro markdown). Does not affect which stays as the API product name. + protected virtual string? LayoutPageTitle => null; + private string? GetGitHubDocsUrl() { var repo = BuildContext.Git.RepositoryName; @@ -61,11 +64,18 @@ public HtmlString RenderMarkdown(string? markdown) public ApiLayoutViewModel CreateGlobalLayoutModel() { var rootPath = BuildContext.SiteRootPath ?? GetDefaultRootPath(BuildContext.UrlPathPrefix); + var docTitle = Document.Info?.Title ?? "API Documentation"; + var pageTitle = LayoutPageTitle; + var documentTitle = pageTitle is not null + ? $"{pageTitle} | {docTitle}" + : docTitle; + return new() { DocsBuilderVersion = ShortId.Create(BuildContext.Version), DocSetName = "Api Explorer", Description = "", + Title = documentTitle, CurrentNavigationItem = CurrentNavigationItem, Previous = null, Next = null, @@ -81,7 +91,7 @@ public ApiLayoutViewModel CreateGlobalLayoutModel() BuildType = BuildContext.BuildType, TocItems = GetTocItems(), // Header properties for isolated mode - HeaderTitle = Document.Info?.Title ?? "API Documentation", + HeaderTitle = docTitle, HeaderVersion = Document.Info?.Version ?? "1.0", GitBranch = BuildContext.Git.Branch != "unavailable" ? BuildContext.Git.Branch : null, GitCommitShort = BuildContext.Git.Ref is { Length: >= 7 } r && r != "unavailable" ? r[..7] : null, diff --git a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml index 50daa89acf..fb7013098d 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml +++ b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml @@ -1,4 +1,5 @@ @inherits RazorSliceHttpResult +@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 0000000000..06b715d979 --- /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(); +} +
+ @Model.BodyHtml +
diff --git a/src/Elastic.ApiExplorer/Landing/MarkdownPageViewModel.cs b/src/Elastic.ApiExplorer/Landing/MarkdownPageViewModel.cs new file mode 100644 index 0000000000..f9a6522060 --- /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 965369a6d8..0000000000 --- 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 9dc1bde513..0000000000 --- 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 e828b8cabc..0000000000 --- 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 a68062c9ae..feb2fdddb7 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,141 @@ 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("/", "-").Trim('-').ToLowerInvariant(); + _ = operationMonikers.Add(moniker); + } + } + + // Add intro and outro markdown pages if available + var finalNavigationItems = new List(); + + // Add intro pages first + if (apiConfig?.IntroMarkdownFiles.Count > 0) + { + foreach (var introFile in apiConfig.IntroMarkdownFiles) + { + var introNavItem = CreateMarkdownNavigationItem(apiUrlSuffix, introFile, rootNavigation, rootNavigation, operationMonikers); + 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); + 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) + { + var slug = SimpleMarkdownNavigationItem.CreateSlugFromFile(markdownFile); + + 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 static string GetNavigationTitleFromFile(IFileInfo markdownFile) + { + try + { + // Read file content to parse frontmatter + var content = 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 +366,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 +391,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 0000000000..014b75fbd0 --- /dev/null +++ b/src/Elastic.ApiExplorer/SimpleMarkdownNavigationItem.cs @@ -0,0 +1,79 @@ +// 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 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 9fd026f18a..0000000000 --- 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 401c58eaa8..20c038bcc8 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 e06ccd7f64..786463881d 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 at least one spec and all entries are valid. + /// + public bool IsValid => Entries.All(e => e.IsValid) && Entries.Any(e => e.IsOpenApiSpec); +} + /// /// 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() + { + foreach (var file in IntroMarkdownFiles) + yield return Path.GetRelativePath(Environment.CurrentDirectory, file.FullName); + foreach (var file in OutroMarkdownFiles) + yield return Path.GetRelativePath(Environment.CurrentDirectory, file.FullName); + } } diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs index 5b0892b8b4..af6c63e949 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 c21280f707..98c092de44 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 20e486d888..6efacc17fd 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 159103a882..8d18f1a1a2 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,37 @@ 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) + { + 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(relativePath, 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(relativePath, outroRelativePath, StringComparison.OrdinalIgnoreCase)) + return true; + } + } + + return false; + } + } diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 4f309b47f2..cd1d29ccf0 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 ee548e9bba..bd60000099 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 9d7d9674a6..592f88e885 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 0000000000..94d450c05c --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs @@ -0,0 +1,321 @@ +// 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.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.FileProviders; +using Microsoft.Extensions.Logging.Abstractions; +using Nullean.ScopedFileSystem; + +namespace Elastic.ApiExplorer.Tests; + +/// +/// Intro markdown under api: must appear in the left-hand API nav (same tree as tags/operations). +/// Fixtures: repository docs/kibana-api-overview.md and docs/kibana-openapi.json. +/// +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

"; + } + + [Fact] + public async Task CreateNavigation_KibanaIntro_IsLeafAndAppearsInNavHtml() + { + 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); + + introFile.Exists.Should().BeTrue(); + specFile.Exists.Should().BeTrue(); + + 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 = await OpenApiReader.Create(specFile); + + doc.Should().NotBeNull(); + + var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); + var navigation = generator.CreateNavigation("kibana", doc, apiConfig); + + navigation.NavigationItems.Should().NotBeEmpty(); + var introNav = navigation.NavigationItems.OfType().FirstOrDefault(); + introNav.Should().NotBeNull(); + introNav.Should().BeAssignableTo>(); + introNav.NavigationTitle.Should().Be("Spaces"); + + var writer = new IsolatedBuildNavigationHtmlWriter(context, navigation); + var result = await writer.RenderNavigation(navigation, introNav, TestContext.Current.CancellationToken); + + result.Html.Should().Contain("Spaces"); + result.Html.Should().Contain("kibana-api-overview"); + } + + [Fact] + public async Task RenderAsync_IntroMarkdown_UsesApiLayoutChrome() + { + 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); + + introFile.Exists.Should().BeTrue(); + specFile.Exists.Should().BeTrue(); + + 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 = await OpenApiReader.Create(specFile); + + doc.Should().NotBeNull(); + + var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); + var navigation = generator.CreateNavigation("kibana", doc, apiConfig); + var introNav = navigation.NavigationItems.OfType().FirstOrDefault(); + introNav.Should().NotBeNull(); + + var hashProvider = new StaticFileContentHashProvider(new EmbeddedOrPhysicalFileProvider(context)); + var renderContext = new ApiRenderContext(context, doc, hashProvider) + { + NavigationHtml = "", + CurrentNavigation = introNav, + MarkdownRenderer = new StubMarkdownRenderer() + }; + + // ScopedFileSystem only allows paths under configured scope roots (repo), not OS temp. + var artifactsDir = Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "api-explorer-tests"); + context.WriteFileSystem.Directory.CreateDirectory(artifactsDir); + var tmp = Path.Combine(artifactsDir, $"api-md-{Guid.NewGuid():N}.html"); + try + { + await using (var stream = context.WriteFileSystem.FileStream.New(tmp, FileMode.Create)) + await introNav.RenderAsync(stream, renderContext, TestContext.Current.CancellationToken); + + var html = await context.WriteFileSystem.File.ReadAllTextAsync(tmp, TestContext.Current.CancellationToken); + html.Should().Contain("id=\"markdown-content\""); + html.Should().Contain("elastic-docs-v3"); + html.Should().Contain("stub-body"); + html.Should().Contain("Spaces |"); + html.Should().Contain("<h1>Kibana spaces</h1>"); + } + finally + { + if (context.WriteFileSystem.File.Exists(tmp)) + context.WriteFileSystem.File.Delete(tmp); + } + } + + [Fact] + public async Task CreateNavigation_IntroAndOutroPages_AppearsInCorrectOrder() + { + 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); + + // Create a mock outro file for testing + var mockOutroPath = Path.Combine(root, "docs", "kibana-api-outro.md"); + var mockOutroFile = new MockFileInfo(mockOutroPath, "# Outro\n\nEnd of API documentation."); + + var apiConfig = new ResolvedApiConfiguration + { + ProductKey = "kibana", + IntroMarkdownFiles = [introFile], + SpecFiles = [specFile], + OutroMarkdownFiles = [mockOutroFile] + }; + + var collector = new DiagnosticsCollector([]); + var configurationContext = TestHelpers.CreateConfigurationContext(fs); + var context = new BuildContext(collector, FileSystemFactory.RealRead, configurationContext); + var doc = await OpenApiReader.Create(specFile); + + doc.Should().NotBeNull(); + + var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); + var navigation = generator.CreateNavigation("kibana", doc, apiConfig); + + navigation.NavigationItems.Should().NotBeEmpty(); + + // Verify navigation order: intro pages should be first + var firstItem = navigation.NavigationItems.First(); + firstItem.Should().BeOfType<SimpleMarkdownNavigationItem>(); + ((SimpleMarkdownNavigationItem)firstItem).NavigationTitle.Should().Be("Spaces"); + + // Verify outro pages should be last + var lastItem = navigation.NavigationItems.Last(); + lastItem.Should().BeOfType<SimpleMarkdownNavigationItem>(); + ((SimpleMarkdownNavigationItem)lastItem).NavigationTitle.Should().Be("Outro"); + + // Verify there are operation/tag items between intro and outro + var middleItems = navigation.NavigationItems.Skip(1).Take(navigation.NavigationItems.Count - 2); + middleItems.Should().NotBeEmpty("There should be generated API content between intro and outro pages"); + } + + [Fact] + public async Task CreateNavigation_IntroPages_GeneratesCorrectUrls() + { + 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 = await OpenApiReader.Create(specFile); + + doc.Should().NotBeNull(); + + var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); + var navigation = generator.CreateNavigation("kibana", doc, apiConfig); + + var introNav = navigation.NavigationItems.OfType<SimpleMarkdownNavigationItem>().FirstOrDefault(); + introNav.Should().NotBeNull(); + + // Verify URL generation follows expected pattern: /api/{product}/{slug}/ + introNav.Url.Should().Be("/api/kibana/kibana-api-overview/"); + introNav.Slug.Should().Be("kibana-api-overview"); + } + + [Fact] + public void CreateNavigation_IntroPages_DetectsUrlCollisions() + { + // Test collision with reserved segments + var act1 = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( + "types", "kibana", "/docs/types.md"); + act1.Should().Throw<InvalidOperationException>() + .WithMessage("*conflicts with reserved API Explorer segment*types*"); + + var act2 = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( + "tags", "kibana", "/docs/tags.md"); + act2.Should().Throw<InvalidOperationException>() + .WithMessage("*conflicts with reserved API Explorer segment*tags*"); + + // Test collision with operation monikers + var operationMonikers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "search", "index" }; + var act3 = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( + "search", "kibana", "/docs/search.md", operationMonikers); + act3.Should().Throw<InvalidOperationException>() + .WithMessage("*conflicts with existing operation moniker*"); + + // Verify valid slugs pass validation + var act4 = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( + "overview", "kibana", "/docs/overview.md", operationMonikers); + act4.Should().NotThrow(); + } + + [Fact] + public async Task RenderNavigationItems_IntroPages_DoesNotDoubleEmitHtml() + { + 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 = await OpenApiReader.Create(specFile); + + doc.Should().NotBeNull(); + + var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); + var navigation = generator.CreateNavigation("kibana", doc, apiConfig); + + // Simulate the dual pipeline check: + // The intro file should be registered to be skipped in regular HTML generation + var introNav = navigation.NavigationItems.OfType<SimpleMarkdownNavigationItem>().FirstOrDefault(); + introNav.Should().NotBeNull(); + + // The key test: verify that intro files would be handled only by API pipeline + // In a real scenario, DocumentationGenerator.ProcessFile would skip files + // that are registered as API intro/outro files to prevent double emission + var relativePath = Path.GetRelativePath(context.Configuration.DocsPath, introPath); + relativePath.Should().Be("kibana-api-overview.md"); + + // This simulates the check that would happen in DocumentationGenerator + // to prevent duplicate HTML generation for intro/outro files + var isApiIntroFile = apiConfig.IntroMarkdownFiles.Any(f => + Path.GetRelativePath(context.Configuration.DocsPath, f.FullName) == relativePath); + isApiIntroFile.Should().BeTrue("Intro file should be registered to prevent duplicate HTML generation"); + } + + /// <summary> + /// Mock file info for testing outro functionality without requiring actual files. + /// </summary> + private class MockFileInfo : IFileInfo + { + private readonly string _path; + private readonly string _content; + + public MockFileInfo(string path, string content) + { + _path = path; + _content = content; + } + + public bool Exists => true; + public long Length => _content.Length; + public string? PhysicalPath => _path; + public string Name => Path.GetFileName(_path); + public DateTimeOffset LastModified => DateTimeOffset.Now; + public bool IsDirectory => false; + public string FullName => _path; + + public Stream CreateReadStream() => new MemoryStream(System.Text.Encoding.UTF8.GetBytes(_content)); + } +} diff --git a/tests/Elastic.ApiExplorer.Tests/SimpleMarkdownNavigationItemTests.cs b/tests/Elastic.ApiExplorer.Tests/SimpleMarkdownNavigationItemTests.cs new file mode 100644 index 0000000000..a8a4625662 --- /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<InvalidOperationException>() + .WithMessage($"*conflicts with reserved API Explorer segment*{reservedSegment}*"); + } + + [Fact] + public void ValidateSlugForCollisions_ThrowsForOperationMoniker() + { + var operationMonikers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "search", "index", "get" }; + + var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( + "search", "elasticsearch", "/docs/search.md", operationMonikers); + + act.Should().Throw<InvalidOperationException>() + .WithMessage("*conflicts with existing operation moniker*"); + } + + [Fact] + public void ValidateSlugForCollisions_AllowsValidSlug() + { + var operationMonikers = new HashSet<string>(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<string>(StringComparer.OrdinalIgnoreCase) { "search", "index", "get" }; + + var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( + "SEARCH", "elasticsearch", "/docs/search.md", operationMonikers); + + act.Should().Throw<InvalidOperationException>() + .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 4a74f3ed9b..0000000000 --- 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<IMarkdownStringRenderer>(); - _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 = "<h1>API Overview</h1><p>This is a custom template.</p>"; - - 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<string>._, A<IFileInfo>._)).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<OperationCanceledException>(); - } - - [Fact] - public void TemplateProcessorFactory_Create_ReturnsTemplateProcessor() - { - // Arrange - var mockRenderer = A.Fake<IMarkdownStringRenderer>(); - var mockFileSystem = new MockFileSystem(); - - // Act - var processor = TemplateProcessorFactory.Create(mockRenderer, mockFileSystem); - - // Assert - processor.Should().NotBeNull(); - processor.Should().BeOfType<TemplateProcessor>(); - } -} diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index 4098c46dd5..c74459612e 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,93 @@ 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_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().BeTrue(); + 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 +161,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 +176,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 +196,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 +212,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 +229,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 +247,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 +272,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<ApiProductSequence>(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<ApiProductSequence>(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<ApiProductSequence>(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<ApiProductSequence>(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<string, ApiConfiguration> + Api = new Dictionary<string, ApiProductSequence> { ["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 +379,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 +405,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 7971b2e678..30456cd5dd 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 99c0606f2b..0a392e842b 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(); From 1b50f6561ae416dcd3eb709b485350b75192883c Mon Sep 17 00:00:00 2001 From: lcawl <lcawley@elastic.co> Date: Fri, 17 Apr 2026 18:07:23 -0700 Subject: [PATCH 2/3] Fix Elastic.ApiExplorer.Tests --- .../KibanaApiMarkdownNavigationTests.cs | 264 ++---------------- 1 file changed, 30 insertions(+), 234 deletions(-) diff --git a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs index 94d450c05c..3e61cccccb 100644 --- a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs @@ -5,6 +5,7 @@ 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; @@ -12,28 +13,24 @@ using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging.Abstractions; using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; /// <summary> -/// Intro markdown under <c>api:</c> must appear in the left-hand API nav (same tree as tags/operations). -/// Fixtures: repository <c>docs/kibana-api-overview.md</c> and <c>docs/kibana-openapi.json</c>. +/// Tests for intro markdown pages in API Explorer navigation. /// </summary> public class KibanaApiMarkdownNavigationTests { private sealed class StubMarkdownRenderer : IMarkdownStringRenderer { public string Render(string markdown, IFileInfo? source) => "<p>stub-body</p>"; - public string RenderPreservingFirstHeading(string markdown, IFileInfo? source) => "<h1>Kibana spaces</h1><p>stub-body</p>"; } - [Fact] - public async Task CreateNavigation_KibanaIntro_IsLeafAndAppearsInNavHtml() + private static (LandingNavigationItem navigation, SimpleMarkdownNavigationItem introNav) SetupKibanaNavigation() { var root = Paths.WorkingDirectoryRoot.FullName; var introPath = Path.Combine(root, "docs", "kibana-api-overview.md"); @@ -42,9 +39,6 @@ public async Task CreateNavigation_KibanaIntro_IsLeafAndAppearsInNavHtml() var introFile = fs.FileInfo.New(introPath); var specFile = fs.FileInfo.New(specPath); - introFile.Exists.Should().BeTrue(); - specFile.Exists.Should().BeTrue(); - var apiConfig = new ResolvedApiConfiguration { ProductKey = "kibana", @@ -55,267 +49,69 @@ public async Task CreateNavigation_KibanaIntro_IsLeafAndAppearsInNavHtml() var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(fs); var context = new BuildContext(collector, FileSystemFactory.RealRead, configurationContext); - var doc = await OpenApiReader.Create(specFile); - - doc.Should().NotBeNull(); - + 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<SimpleMarkdownNavigationItem>().First(); - navigation.NavigationItems.Should().NotBeEmpty(); - var introNav = navigation.NavigationItems.OfType<SimpleMarkdownNavigationItem>().FirstOrDefault(); - introNav.Should().NotBeNull(); - introNav.Should().BeAssignableTo<ILeafNavigationItem<IApiModel>>(); - introNav.NavigationTitle.Should().Be("Spaces"); - - var writer = new IsolatedBuildNavigationHtmlWriter(context, navigation); - var result = await writer.RenderNavigation(navigation, introNav, TestContext.Current.CancellationToken); - - result.Html.Should().Contain("Spaces"); - result.Html.Should().Contain("kibana-api-overview"); + return (navigation, introNav); } [Fact] - public async Task RenderAsync_IntroMarkdown_UsesApiLayoutChrome() + public void IntroNav_ShouldBeLeafNavigationItem() { - 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); - - introFile.Exists.Should().BeTrue(); - specFile.Exists.Should().BeTrue(); - - 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 = await OpenApiReader.Create(specFile); - - doc.Should().NotBeNull(); - - var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); - var navigation = generator.CreateNavigation("kibana", doc, apiConfig); - var introNav = navigation.NavigationItems.OfType<SimpleMarkdownNavigationItem>().FirstOrDefault(); - introNav.Should().NotBeNull(); - - var hashProvider = new StaticFileContentHashProvider(new EmbeddedOrPhysicalFileProvider(context)); - var renderContext = new ApiRenderContext(context, doc, hashProvider) - { - NavigationHtml = "", - CurrentNavigation = introNav, - MarkdownRenderer = new StubMarkdownRenderer() - }; - - // ScopedFileSystem only allows paths under configured scope roots (repo), not OS temp. - var artifactsDir = Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "api-explorer-tests"); - context.WriteFileSystem.Directory.CreateDirectory(artifactsDir); - var tmp = Path.Combine(artifactsDir, $"api-md-{Guid.NewGuid():N}.html"); - try - { - await using (var stream = context.WriteFileSystem.FileStream.New(tmp, FileMode.Create)) - await introNav.RenderAsync(stream, renderContext, TestContext.Current.CancellationToken); + var (_, introNav) = SetupKibanaNavigation(); - var html = await context.WriteFileSystem.File.ReadAllTextAsync(tmp, TestContext.Current.CancellationToken); - html.Should().Contain("id=\"markdown-content\""); - html.Should().Contain("elastic-docs-v3"); - html.Should().Contain("stub-body"); - html.Should().Contain("<title>Spaces |"); - html.Should().Contain("<h1>Kibana spaces</h1>"); - } - finally - { - if (context.WriteFileSystem.File.Exists(tmp)) - context.WriteFileSystem.File.Delete(tmp); - } + introNav.Should().BeAssignableTo<ILeafNavigationItem<IApiModel>>(); + introNav.NavigationTitle.Should().Be("Spaces"); } [Fact] - public async Task CreateNavigation_IntroAndOutroPages_AppearsInCorrectOrder() + public void IntroNav_ShouldAppearFirstInNavigation() { - 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); - - // Create a mock outro file for testing - var mockOutroPath = Path.Combine(root, "docs", "kibana-api-outro.md"); - var mockOutroFile = new MockFileInfo(mockOutroPath, "# Outro\n\nEnd of API documentation."); - - var apiConfig = new ResolvedApiConfiguration - { - ProductKey = "kibana", - IntroMarkdownFiles = [introFile], - SpecFiles = [specFile], - OutroMarkdownFiles = [mockOutroFile] - }; + var (navigation, introNav) = SetupKibanaNavigation(); - var collector = new DiagnosticsCollector([]); - var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var context = new BuildContext(collector, FileSystemFactory.RealRead, configurationContext); - var doc = await OpenApiReader.Create(specFile); - - doc.Should().NotBeNull(); - - var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); - var navigation = generator.CreateNavigation("kibana", doc, apiConfig); - - navigation.NavigationItems.Should().NotBeEmpty(); - - // Verify navigation order: intro pages should be first var firstItem = navigation.NavigationItems.First(); + firstItem.Should().Be(introNav); firstItem.Should().BeOfType<SimpleMarkdownNavigationItem>(); - ((SimpleMarkdownNavigationItem)firstItem).NavigationTitle.Should().Be("Spaces"); - - // Verify outro pages should be last - var lastItem = navigation.NavigationItems.Last(); - lastItem.Should().BeOfType<SimpleMarkdownNavigationItem>(); - ((SimpleMarkdownNavigationItem)lastItem).NavigationTitle.Should().Be("Outro"); - - // Verify there are operation/tag items between intro and outro - var middleItems = navigation.NavigationItems.Skip(1).Take(navigation.NavigationItems.Count - 2); - middleItems.Should().NotBeEmpty("There should be generated API content between intro and outro pages"); } [Fact] - public async Task CreateNavigation_IntroPages_GeneratesCorrectUrls() + public void IntroNav_ShouldGenerateCorrectUrl() { - 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 (_, introNav) = SetupKibanaNavigation(); - 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 = await OpenApiReader.Create(specFile); - - doc.Should().NotBeNull(); - - var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); - var navigation = generator.CreateNavigation("kibana", doc, apiConfig); - - var introNav = navigation.NavigationItems.OfType<SimpleMarkdownNavigationItem>().FirstOrDefault(); - introNav.Should().NotBeNull(); - - // Verify URL generation follows expected pattern: /api/{product}/{slug}/ introNav.Url.Should().Be("/api/kibana/kibana-api-overview/"); - introNav.Slug.Should().Be("kibana-api-overview"); } [Fact] - public void CreateNavigation_IntroPages_DetectsUrlCollisions() + public void UrlCollisionValidation_ShouldDetectReservedSegments() { - // Test collision with reserved segments - var act1 = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( - "types", "kibana", "/docs/types.md"); - act1.Should().Throw<InvalidOperationException>() - .WithMessage("*conflicts with reserved API Explorer segment*types*"); - - var act2 = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( - "tags", "kibana", "/docs/tags.md"); - act2.Should().Throw<InvalidOperationException>() - .WithMessage("*conflicts with reserved API Explorer segment*tags*"); + var actTypes = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions("types", "kibana", "/docs/types.md"); + var actTags = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions("tags", "kibana", "/docs/tags.md"); - // Test collision with operation monikers - var operationMonikers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "search", "index" }; - var act3 = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( - "search", "kibana", "/docs/search.md", operationMonikers); - act3.Should().Throw<InvalidOperationException>() - .WithMessage("*conflicts with existing operation moniker*"); - - // Verify valid slugs pass validation - var act4 = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions( - "overview", "kibana", "/docs/overview.md", operationMonikers); - act4.Should().NotThrow(); + actTypes.Should().Throw<InvalidOperationException>().WithMessage("*conflicts with reserved API Explorer segment*types*"); + actTags.Should().Throw<InvalidOperationException>().WithMessage("*conflicts with reserved API Explorer segment*tags*"); } [Fact] - public async Task RenderNavigationItems_IntroPages_DoesNotDoubleEmitHtml() + public void UrlCollisionValidation_ShouldDetectOperationMonikers() { - 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 = await OpenApiReader.Create(specFile); - - doc.Should().NotBeNull(); - - var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); - var navigation = generator.CreateNavigation("kibana", doc, apiConfig); - - // Simulate the dual pipeline check: - // The intro file should be registered to be skipped in regular HTML generation - var introNav = navigation.NavigationItems.OfType<SimpleMarkdownNavigationItem>().FirstOrDefault(); - introNav.Should().NotBeNull(); + var operationMonikers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "search", "index" }; - // The key test: verify that intro files would be handled only by API pipeline - // In a real scenario, DocumentationGenerator.ProcessFile would skip files - // that are registered as API intro/outro files to prevent double emission - var relativePath = Path.GetRelativePath(context.Configuration.DocsPath, introPath); - relativePath.Should().Be("kibana-api-overview.md"); + var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions("search", "kibana", "/docs/search.md", operationMonikers); - // This simulates the check that would happen in DocumentationGenerator - // to prevent duplicate HTML generation for intro/outro files - var isApiIntroFile = apiConfig.IntroMarkdownFiles.Any(f => - Path.GetRelativePath(context.Configuration.DocsPath, f.FullName) == relativePath); - isApiIntroFile.Should().BeTrue("Intro file should be registered to prevent duplicate HTML generation"); + act.Should().Throw<InvalidOperationException>().WithMessage("*conflicts with existing operation moniker*"); } - /// <summary> - /// Mock file info for testing outro functionality without requiring actual files. - /// </summary> - private class MockFileInfo : IFileInfo + [Fact] + public void UrlCollisionValidation_ShouldAllowValidSlugs() { - private readonly string _path; - private readonly string _content; - - public MockFileInfo(string path, string content) - { - _path = path; - _content = content; - } + var operationMonikers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "search", "index" }; - public bool Exists => true; - public long Length => _content.Length; - public string? PhysicalPath => _path; - public string Name => Path.GetFileName(_path); - public DateTimeOffset LastModified => DateTimeOffset.Now; - public bool IsDirectory => false; - public string FullName => _path; + var act = () => SimpleMarkdownNavigationItem.ValidateSlugForCollisions("overview", "kibana", "/docs/overview.md", operationMonikers); - public Stream CreateReadStream() => new MemoryStream(System.Text.Encoding.UTF8.GetBytes(_content)); + act.Should().NotThrow(); } } From 4ce22e44a20e01b69a9392a39bd131c44f1c3172 Mon Sep 17 00:00:00 2001 From: lcawl <lcawley@elastic.co> Date: Fri, 17 Apr 2026 18:19:35 -0700 Subject: [PATCH 3/3] Address CodeRabbit feedback --- docs/configure/content-set/api-explorer.md | 2 +- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 22 ++++++++++++++----- .../SimpleMarkdownNavigationItem.cs | 1 + .../Toc/ApiConfiguration.cs | 10 ++++----- .../DocumentationGenerator.cs | 6 +++-- .../Http/ReloadableGeneratorState.cs | 2 +- .../KibanaApiMarkdownNavigationTests.cs | 1 + .../ApiConfigurationTests.cs | 18 ++++++++++++++- 8 files changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/configure/content-set/api-explorer.md b/docs/configure/content-set/api-explorer.md index a2d3a8f427..141eccc7a2 100644 --- a/docs/configure/content-set/api-explorer.md +++ b/docs/configure/content-set/api-explorer.md @@ -80,7 +80,7 @@ Make sure you have: - A running Kibana instance - Valid authentication credentials -- Understanding of [RESTful API principles]({# TODO: link #}) +- Understanding of RESTful API principles ``` ## Place your spec files diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index feb2fdddb7..d95a75858f 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -141,20 +141,21 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume // Use same moniker logic as OperationNavigationItem var moniker = !string.IsNullOrWhiteSpace(operation.Value.OperationId) ? operation.Value.OperationId - : path.Key.Replace("{", "").Replace("}", "").Replace("/", "-").Trim('-').ToLowerInvariant(); + : path.Key.Replace("}", "").Replace("{", "").Replace('/', '-'); _ = operationMonikers.Add(moniker); } } // Add intro and outro markdown pages if available var finalNavigationItems = new List<INavigationItem>(); + var markdownSlugs = new HashSet<string>(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); + var introNavItem = CreateMarkdownNavigationItem(apiUrlSuffix, introFile, rootNavigation, rootNavigation, operationMonikers, markdownSlugs); finalNavigationItems.Add(introNavItem); } } @@ -170,7 +171,7 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume { foreach (var outroFile in apiConfig.OutroMarkdownFiles) { - var outroNavItem = CreateMarkdownNavigationItem(apiUrlSuffix, outroFile, rootNavigation, rootNavigation, operationMonikers); + var outroNavItem = CreateMarkdownNavigationItem(apiUrlSuffix, outroFile, rootNavigation, rootNavigation, operationMonikers, markdownSlugs); finalNavigationItems.Add(outroNavItem); } } @@ -187,10 +188,19 @@ private SimpleMarkdownNavigationItem CreateMarkdownNavigationItem( IFileInfo markdownFile, LandingNavigationItem rootNavigation, INodeNavigationItem<INavigationModel, INavigationItem> parent, - HashSet<string> operationMonikers) + HashSet<string> operationMonikers, + HashSet<string> 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}/"; @@ -205,12 +215,12 @@ private SimpleMarkdownNavigationItem CreateMarkdownNavigationItem( return navItem; } - private static string GetNavigationTitleFromFile(IFileInfo markdownFile) + private string GetNavigationTitleFromFile(IFileInfo markdownFile) { try { // Read file content to parse frontmatter - var content = File.ReadAllText(markdownFile.FullName); + var content = context.ReadFileSystem.File.ReadAllText(markdownFile.FullName); // Simple frontmatter parsing - look for navigation_title if (content.StartsWith("---")) diff --git a/src/Elastic.ApiExplorer/SimpleMarkdownNavigationItem.cs b/src/Elastic.ApiExplorer/SimpleMarkdownNavigationItem.cs index 014b75fbd0..fe80558a93 100644 --- a/src/Elastic.ApiExplorer/SimpleMarkdownNavigationItem.cs +++ b/src/Elastic.ApiExplorer/SimpleMarkdownNavigationItem.cs @@ -30,6 +30,7 @@ public class SimpleMarkdownNavigationItem( 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); /// <inheritdoc /> public IApiModel Model => this; diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs index 786463881d..925c5cec1b 100644 --- a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs @@ -107,9 +107,9 @@ public IEnumerable<string> GetSpecPaths() => Entries .Select(e => e.Spec!); /// <summary> - /// Validates that the sequence has at least one spec and all entries are valid. + /// Validates that the sequence has exactly one spec and all entries are valid. /// </summary> - public bool IsValid => Entries.All(e => e.IsValid) && Entries.Any(e => e.IsOpenApiSpec); + public bool IsValid => Entries.All(e => e.IsValid) && Entries.Count(e => e.IsOpenApiSpec) == 1; } /// <summary> @@ -192,11 +192,11 @@ public class ResolvedApiConfiguration /// <summary> /// Gets all Markdown file paths that should be excluded from normal HTML generation. /// </summary> - public IEnumerable<string> GetMarkdownPathsToExclude() + public IEnumerable<string> GetMarkdownPathsToExclude(string documentationSourceDirectoryFullName) { foreach (var file in IntroMarkdownFiles) - yield return Path.GetRelativePath(Environment.CurrentDirectory, file.FullName); + yield return Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/'); foreach (var file in OutroMarkdownFiles) - yield return Path.GetRelativePath(Environment.CurrentDirectory, file.FullName); + yield return Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/'); } } diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 8d18f1a1a2..265b87d6b0 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -463,6 +463,8 @@ public async Task<RenderResult> RenderLayout(MarkdownFile markdown, Cancel ctx) /// </summary> private bool IsApiMarkdownFile(string relativePath) { + var normalized = relativePath.Replace(Path.DirectorySeparatorChar, '/'); + if (Context.Configuration.ApiConfigurations == null) return false; @@ -473,7 +475,7 @@ private bool IsApiMarkdownFile(string relativePath) { var introRelativePath = Path.GetRelativePath(Context.DocumentationSourceDirectory.FullName, introFile.FullName) .Replace(Path.DirectorySeparatorChar, '/'); - if (string.Equals(relativePath, introRelativePath, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(normalized, introRelativePath, StringComparison.OrdinalIgnoreCase)) return true; } @@ -482,7 +484,7 @@ private bool IsApiMarkdownFile(string relativePath) { var outroRelativePath = Path.GetRelativePath(Context.DocumentationSourceDirectory.FullName, outroFile.FullName) .Replace(Path.DirectorySeparatorChar, '/'); - if (string.Equals(relativePath, outroRelativePath, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(normalized, outroRelativePath, StringComparison.OrdinalIgnoreCase)) return true; } } diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 592f88e885..70acd4d757 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -130,7 +130,7 @@ private bool HaveOpenApiSpecsChanged(ConfigurationFile config) return false; // First run - no timestamps yet - if (_openApiSpecLastModified.Count == 0 || _apiMarkdownFilesLastModified.Count == 0) + if (_openApiSpecLastModified.Count == 0 && _apiMarkdownFilesLastModified.Count == 0) return true; // Check legacy OpenAPI specification files diff --git a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs index 3e61cccccb..4ba1c8772f 100644 --- a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs @@ -65,6 +65,7 @@ public void IntroNav_ShouldBeLeafNavigationItem() introNav.Should().BeAssignableTo<ILeafNavigationItem<IApiModel>>(); introNav.NavigationTitle.Should().Be("Spaces"); + introNav.Slug.Should().Be("kibana-api-overview"); } [Fact] diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index c74459612e..f9c9a6ddff 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs @@ -102,6 +102,22 @@ public void ApiProductSequence_ValidWithIntroSpecOutro() 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() { @@ -117,7 +133,7 @@ public void ApiProductSequence_SeparatesIntroAndOutroFiles() ] }; - sequence.IsValid.Should().BeTrue(); + 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"]);