From 4cd99a5b20d7a11c454e7aabafc3a658f424e0d1 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 11:05:21 +0200 Subject: [PATCH 01/13] Discover API Explorer supplemental files by naming convention Authors need op-*.md and tag-*.md discovery before merge into rendered pages. Assembler previews also need the API Explorer flag so those pages appear on docs-v3-preview. Co-authored-by: Cursor --- config/assembler.yml | 1 + docs/_docset.yml | 8 + docs/api/elasticsearch/op-async-search-get.md | 3 + docs/data/openapi/api-explorer.md | 5 +- .../Infrastructure/ApiUrlBuilder.cs | 17 +- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 30 +++ .../Supplemental/ApiSupplementalDiscovery.cs | 198 ++++++++++++++++++ .../Supplemental/ApiSupplementalName.cs | 64 ++++++ .../Builder/ConfigurationFile.cs | 12 +- .../Toc/ApiConfiguration.cs | 56 ++++- .../Building/SitemapBuilder.cs | 2 +- .../Http/ReloadableGeneratorState.cs | 55 +++-- .../AssemblerConfigurationTests.cs | 15 +- .../ApiSupplementalDiscoveryTests.cs | 147 +++++++++++++ .../Supplemental/ApiSupplementalNameTests.cs | 63 ++++++ .../OpenApiGeneratorSupplementalTests.cs | 46 ++++ .../FeatureFlagsTests.cs | 17 +- .../ApiConfigurationTests.cs | 91 +++++++- .../PhysicalDocsetTests.cs | 6 +- 19 files changed, 798 insertions(+), 38 deletions(-) create mode 100644 docs/api/elasticsearch/op-async-search-get.md create mode 100644 src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs create mode 100644 src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs create mode 100644 tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs create mode 100644 tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs create mode 100644 tests/Elastic.ApiExplorer.Tests/Supplemental/OpenApiGeneratorSupplementalTests.cs diff --git a/config/assembler.yml b/config/assembler.yml index a53be2be09..e833d5fe1f 100644 --- a/config/assembler.yml +++ b/config/assembler.yml @@ -58,6 +58,7 @@ environments: enabled: false feature_flags: NAVIGATION_PREVIEW: true + ASSEMBLER_API_EXPLORER: true air-gapped: uri: http://localhost:8080 path_prefix: docs diff --git a/docs/_docset.yml b/docs/_docset.yml index 0f27dcbb98..3e46e25f97 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -38,6 +38,14 @@ subs: features: primary-nav: false +# Isolated `docs-builder serve` / `build` only. This repo is `skip: true` in assembler.yml, +# so assembler does not publish these pages. The local spec overrides remote resolution. +# Run serve without `--watch`, then open /api/doc/elasticsearch/ +api: + elasticsearch: + - spec: elasticsearch-openapi-docs.json + product: elasticsearch + cta: docs-builder: button: diff --git a/docs/api/elasticsearch/op-async-search-get.md b/docs/api/elasticsearch/op-async-search-get.md new file mode 100644 index 0000000000..cf79a9f9b9 --- /dev/null +++ b/docs/api/elasticsearch/op-async-search-get.md @@ -0,0 +1,3 @@ +This file is a local fixture for docs-builder development. + +Discovery should associate it with the `async-search-get` operation. diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index 63ab48bef3..bd5ea2b890 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -226,15 +226,18 @@ api: ## When the API Explorer runs -The API Explorer generates documentation in two scenarios: +The API Explorer generates documentation in these scenarios: - **`docs-builder build`**: API docs are generated as part of the standard build. Use `--skip-api` to skip generation for faster iteration on content. - **`docs-builder serve`**: API docs are generated on startup and regenerated automatically when spec files change. +- **Assembler builds**: API docs are generated when the `ASSEMBLER_API_EXPLORER` feature flag is on. That flag is on for the `staging` and `preview` environments. Production stays off until cutover. :::{note} API generation is skipped when running `docs-builder serve --watch`. This is a performance optimization for `dotnet watch` workflows. Run `serve` without `--watch` to include API docs in your local preview. ::: +This repository's own `_docset.yml` declares a local `elasticsearch` API that reads `elasticsearch-openapi-docs.json`. Use that entry to preview ApiExplorer and supplemental files during isolated `docs-builder serve`. Assembler skips this repo (`skip: true` in `assembler.yml`), so those pages are not published. + ## Link to API pages in navigation You can reference API pages in your `toc.yml` or `docset.yml` navigation using cross-link syntax: diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs index 7401b2fb52..bf95efbce9 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs @@ -42,11 +42,14 @@ public static string OperationMoniker(string? operationId, string route) public static string SchemaMoniker(string schemaId) => schemaId.Replace('.', '-').ToLowerInvariant(); - /// Deterministic URL leaf for .../group/{segment} from the canonical tag name. - public static string TagMoniker(string? tagName) + /// + /// URL slug for a tag, without the endpoint- prefix. Spaces become hyphens and the + /// result is lowercased; underscores are kept. Empty or whitespace names become unknown. + /// + public static string TagSlug(string? tagName) { if (string.IsNullOrWhiteSpace(tagName)) - return "endpoint-unknown"; + return "unknown"; var s = tagName.Trim(); s = string.Join(" ", s.Split(' ', StringSplitOptions.RemoveEmptyEntries)); @@ -56,12 +59,12 @@ public static string TagMoniker(string? tagName) s = s.Replace("/", "-", StringComparison.Ordinal); s = s.Replace(" ", "-", StringComparison.Ordinal); s = s.ToLowerInvariant(); - if (string.IsNullOrEmpty(s)) - return "endpoint-unknown"; - - return $"endpoint-{s}"; + return string.IsNullOrEmpty(s) ? "unknown" : s; } + /// Deterministic URL leaf for .../group/{segment} from the canonical tag name. + public static string TagMoniker(string? tagName) => $"endpoint-{TagSlug(tagName)}"; + [GeneratedRegex(@"\s*\(([^)]+)\)")] private static partial Regex ParentheticalSuffixPattern(); } diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 1508f00e69..22929c1323 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -9,6 +9,7 @@ using Elastic.ApiExplorer.Model; using Elastic.ApiExplorer.Navigation; using Elastic.ApiExplorer.Operations; +using Elastic.ApiExplorer.Supplemental; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Products; @@ -228,6 +229,7 @@ private async Task GenerateApiProduct( IReadOnlyList versionSwitcherItems, Cancel ctx) { + _ = DiscoverSupplemental(openApiDocument, apiConfig); var navigation = CreateNavigation(prefix, openApiDocument, apiConfig); _logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? ""); @@ -245,6 +247,34 @@ private async Task GenerateApiProduct( await RenderNavigationItems(renderContext, navigationRenderer, navigation, ctx).ConfigureAwait(false); } + /// + /// Associates op-*.md / tag-*.md files under api/<key>/ with this + /// document. Merge into page models is a later change; this call logs and exposes the result. + /// + internal ApiSupplementalDiscoveryResult DiscoverSupplemental( + OpenApiDocument openApiDocument, + ResolvedApiConfiguration? apiConfig) + { + var folder = apiConfig?.ApiContentDirectory; + if (folder is null && apiConfig is not null) + { + folder = context.ReadFileSystem.DirectoryInfo.New( + Path.Join(context.DocumentationSourceDirectory.FullName, "api", apiConfig.ProductKey)); + } + + var result = ApiSupplementalDiscovery.Discover(folder, openApiDocument); + if (result.Operations.Count == 0 && result.Tags.Count == 0 && result.Unmatched.Count == 0) + return result; + + _logger.LogInformation( + "API '{ApiKey}' supplemental files: {Operations} operations, {Tags} tags, {Unmatched} unmatched", + apiConfig?.ProductKey ?? "unknown", + result.Operations.Count, + result.Tags.Count, + result.Unmatched.Count); + return result; + } + private async Task RenderNavigationItems( ApiRenderContext renderContext, IsolatedBuildNavigationHtmlWriter navigationRenderer, diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs new file mode 100644 index 0000000000..9e45650c30 --- /dev/null +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs @@ -0,0 +1,198 @@ +// 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 Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Supplemental; + +public sealed record TagSlugCollision(string Slug, IReadOnlyList TagNames); + +public sealed record ApiSupplementalVersionedFile(IFileInfo File, ApiSupplementalFileName Name); + +public sealed class ApiSupplementalDiscoveryResult +{ + public static ApiSupplementalDiscoveryResult Empty { get; } = new() + { + Operations = new Dictionary(), + Tags = new Dictionary(), + Unmatched = [], + Ignored = [], + VersionSuffixed = [], + TagSlugCollisions = [] + }; + + public required IReadOnlyDictionary Operations { get; init; } + public required IReadOnlyDictionary Tags { get; init; } + public required IReadOnlyList Unmatched { get; init; } + public required IReadOnlyList Ignored { get; init; } + public required IReadOnlyList VersionSuffixed { get; init; } + public required IReadOnlyList TagSlugCollisions { get; init; } +} + +/// +/// Discovers top-level op-*.md / tag-*.md files under api/<key>/. +/// Does not emit diagnostics; unmatched convention files are returned for later validation. +/// +public static class ApiSupplementalDiscovery +{ + public static ApiSupplementalDiscoveryResult Discover( + IDirectoryInfo? folder, + IReadOnlyCollection operationIds, + IReadOnlyCollection tagNames) + { + var collisions = TagCollisions(tagNames); + var collidingSlugs = collisions.Select(c => c.Slug).ToHashSet(StringComparer.Ordinal); + var tagBySlug = UniqueTagBySlug(tagNames, collidingSlugs); + var operationSet = operationIds.ToHashSet(StringComparer.Ordinal); + + if (folder is null || !folder.Exists) + { + return new ApiSupplementalDiscoveryResult + { + Operations = new Dictionary(), + Tags = new Dictionary(), + Unmatched = [], + Ignored = [], + VersionSuffixed = [], + TagSlugCollisions = collisions + }; + } + + var operations = new Dictionary(StringComparer.Ordinal); + var tags = new Dictionary(StringComparer.Ordinal); + var unmatched = new List(); + var ignored = new List(); + var versionSuffixed = new List(); + + foreach (var file in folder.EnumerateFiles("*.md")) + { + if (!ApiSupplementalName.TryParse(file.Name, out var parsed)) + { + ignored.Add(file); + continue; + } + + var name = parsed.Value; + if (name.IsVersionSuffixed) + { + versionSuffixed.Add(new ApiSupplementalVersionedFile(file, name)); + continue; + } + + if (name.Kind == ApiSupplementalKind.Operation) + { + if (operationSet.Contains(name.Stem) && operations.TryAdd(name.Stem, file)) + continue; + unmatched.Add(file); + continue; + } + + if (collidingSlugs.Contains(name.Stem)) + { + unmatched.Add(file); + continue; + } + + if (tagBySlug.TryGetValue(name.Stem, out var tagName) && tags.TryAdd(tagName, file)) + continue; + + unmatched.Add(file); + } + + return new ApiSupplementalDiscoveryResult + { + Operations = operations, + Tags = tags, + Unmatched = unmatched, + Ignored = ignored, + VersionSuffixed = versionSuffixed, + TagSlugCollisions = collisions + }; + } + + public static ApiSupplementalDiscoveryResult Discover(IDirectoryInfo? folder, OpenApiDocument document) + { + CollectEntities(document, out var operationIds, out var tagNames); + return Discover(folder, operationIds, tagNames); + } + + internal static void CollectEntities( + OpenApiDocument document, + out List operationIds, + out List tagNames) + { + var operations = new HashSet(StringComparer.Ordinal); + var tags = new HashSet(StringComparer.Ordinal); + + if (document.Tags is not null) + { + foreach (var tag in document.Tags) + { + if (!string.IsNullOrEmpty(tag.Name)) + _ = tags.Add(tag.Name); + } + } + + foreach (var path in document.Paths ?? []) + { + if (path.Value.Operations is null) + continue; + + foreach (var operation in path.Value.Operations.Values) + { + if (!string.IsNullOrWhiteSpace(operation.OperationId)) + _ = operations.Add(operation.OperationId); + + if (operation.Tags is null) + continue; + + foreach (var tagRef in operation.Tags) + { + var name = tagRef.Reference?.Id; + if (!string.IsNullOrEmpty(name)) + _ = tags.Add(name); + } + } + } + + operationIds = [.. operations]; + tagNames = [.. tags]; + } + + private static IReadOnlyList TagCollisions(IReadOnlyCollection tagNames) + { + var bySlug = new Dictionary>(StringComparer.Ordinal); + foreach (var tagName in tagNames) + { + var slug = ApiSupplementalName.TagFileStem(tagName); + if (!bySlug.TryGetValue(slug, out var names)) + { + names = []; + bySlug[slug] = names; + } + if (!names.Contains(tagName, StringComparer.Ordinal)) + names.Add(tagName); + } + + return [.. bySlug + .Where(kv => kv.Value.Count > 1) + .Select(kv => new TagSlugCollision(kv.Key, kv.Value))]; + } + + private static Dictionary UniqueTagBySlug( + IReadOnlyCollection tagNames, + HashSet collidingSlugs) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var tagName in tagNames) + { + var slug = ApiSupplementalName.TagFileStem(tagName); + if (collidingSlugs.Contains(slug)) + continue; + _ = map.TryAdd(slug, tagName); + } + return map; + } +} diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs new file mode 100644 index 0000000000..fc4d5717ed --- /dev/null +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs @@ -0,0 +1,64 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Elastic.ApiExplorer.Infrastructure; + +namespace Elastic.ApiExplorer.Supplemental; + +public enum ApiSupplementalKind +{ + Operation, + Tag +} + +public readonly record struct ApiSupplementalFileName( + ApiSupplementalKind Kind, + string Stem, + int? VersionMajor) +{ + public bool IsVersionSuffixed => VersionMajor is not null; +} + +/// +/// Parses op-*.md / tag-*.md filenames. Operation stems are the spec +/// operationId with no rewriting. Tag stems are . +/// +public static partial class ApiSupplementalName +{ + public static bool IsConventionFileName(string fileName) => TryParse(fileName, out _); + + public static bool TryParse(string fileName, [NotNullWhen(true)] out ApiSupplementalFileName? parsed) + { + parsed = null; + var match = FileNamePattern().Match(fileName); + if (!match.Success) + return false; + + var kind = match.Groups[1].Value.Equals("op", StringComparison.OrdinalIgnoreCase) + ? ApiSupplementalKind.Operation + : ApiSupplementalKind.Tag; + var stem = match.Groups[2].Value; + if (stem.Length == 0) + return false; + + int? version = null; + if (match.Groups[3].Success) + { + if (!int.TryParse(match.Groups[3].Value, out var major)) + return false; + version = major; + } + + parsed = new ApiSupplementalFileName(kind, stem, version); + return true; + } + + /// File stem for a tag, identical to the URL slug without the endpoint- prefix. + public static string TagFileStem(string tagName) => ApiUrlBuilder.TagSlug(tagName); + + [GeneratedRegex(@"^(op|tag)-(.+?)(?:\.v(\d+))?\.md$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex FileNamePattern(); +} diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index dd9b9a9e1d..41cc28a68f 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -564,6 +564,8 @@ private static bool IsValidProductId(string product) => } var children = ResolveApiChildren(productKey, entry.Children, context); + var apiContentDirectory = context.ReadFileSystem.DirectoryInfo.New( + Path.Join(context.DocumentationSourceDirectory.FullName, "api", productKey)); return new ResolvedApiConfiguration { @@ -572,7 +574,8 @@ private static bool IsValidProductId(string product) => SpecFileName = specFileName, LocalSpecFile = localSpecFile, Repository = repository, - Children = children + Children = children, + ApiContentDirectory = apiContentDirectory }; } @@ -620,6 +623,13 @@ private static List ResolveApiChildren(string productKey, List Children { get; init; } = []; /// - /// Gets all child Markdown file paths that should be excluded from normal HTML generation. + /// The api/<key>/ directory for this product, whether or not it exists yet. + /// Supplemental op-*.md / tag-*.md files are discovered from here. + /// + public IDirectoryInfo? ApiContentDirectory { get; init; } + + /// + /// Whether is an auto-discovered supplemental file + /// (op-*.md or tag-*.md), including version-suffixed names. + /// + public static bool IsSupplementalFileName(string fileName) + { + var name = Path.GetFileName(fileName); + return name.StartsWith("op-", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("tag-", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Markdown paths that must not be rendered by the normal HTML pipeline: + /// explicit children: pages and convention supplemental files. /// public IEnumerable GetMarkdownPathsToExclude(string documentationSourceDirectoryFullName) { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var file in Children) - yield return Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/'); + { + var relative = ToRelativeMarkdownPath(file, documentationSourceDirectoryFullName); + if (seen.Add(relative)) + yield return relative; + } + + if (ApiContentDirectory is not { Exists: true } dir) + yield break; + + foreach (var file in dir.EnumerateFiles("*.md")) + { + if (!IsSupplementalFileName(file.Name)) + continue; + var relative = ToRelativeMarkdownPath(file, documentationSourceDirectoryFullName); + if (seen.Add(relative)) + yield return relative; + } } + + /// Top-level Markdown files under , when the folder exists. + public IEnumerable EnumerateApiMarkdownFiles() + { + if (ApiContentDirectory is not { } dir) + yield break; + + dir.Refresh(); + if (!dir.Exists) + yield break; + + foreach (var file in dir.EnumerateFiles("*.md")) + yield return file; + } + + private static string ToRelativeMarkdownPath(IFileInfo file, string documentationSourceDirectoryFullName) => + Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/'); } diff --git a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs index 06130da5be..6ba4d442b8 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs @@ -30,7 +30,7 @@ public static SitemapResult Generate( IDirectoryInfo outputFolder ) { - // API pages are generated only on staging (assembler-api-explorer flag) and /docs/api/* is still + // API pages are generated on staging and preview (assembler-api-explorer flag). /docs/api/* is still // proxied to bump.sh at the edge (#725). Keep them out of the sitemap until cutover. var filtered = entries .Where(e => !e.Key.StartsWith("/docs/api/", StringComparison.Ordinal)) diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 7e667d6600..1588de600f 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -151,35 +151,55 @@ private bool HaveOpenApiSpecsChanged(ConfigurationFile config) if (config.ApiConfigurations is null) return false; - // First run - no timestamps yet if (_openApiSpecLastModified.Count == 0 && _apiMarkdownFilesLastModified.Count == 0) return true; foreach (var apiConfig in config.ApiConfigurations.Values) { - // The local spec override, when present. A spec with no local file resolves - // remotely and has nothing on disk to watch here. if (apiConfig.LocalSpecFile is { } specFile) { specFile.Refresh(); if (!_openApiSpecLastModified.TryGetValue(specFile.FullName, out var lastModified)) - return true; // New file + return true; if (specFile.LastWriteTimeUtc > lastModified) - return true; // File modified + return true; } + } + + return HaveApiMarkdownFilesChanged(config); + } + + private bool HaveApiMarkdownFilesChanged(ConfigurationFile config) + { + var current = CurrentApiMarkdownTimestamps(config); + if (current.Count != _apiMarkdownFilesLastModified.Count) + return true; + + foreach (var (path, time) in current) + { + if (!_apiMarkdownFilesLastModified.TryGetValue(path, out var lastModified) || time > lastModified) + return true; + } + + return false; + } + + private static Dictionary CurrentApiMarkdownTimestamps(ConfigurationFile config) + { + var current = new Dictionary(StringComparer.Ordinal); + if (config.ApiConfigurations is null) + return current; - // Explicit children declared via 'children:' - foreach (var childFile in apiConfig.Children) + foreach (var apiConfig in config.ApiConfigurations.Values) + { + foreach (var file in apiConfig.EnumerateApiMarkdownFiles()) { - childFile.Refresh(); - if (!_apiMarkdownFilesLastModified.TryGetValue(childFile.FullName, out var lastModified)) - return true; // New file - if (childFile.LastWriteTimeUtc > lastModified) - return true; // File modified + file.Refresh(); + current[file.FullName] = file.LastWriteTimeUtc; } } - return false; + return current; } private void UpdateOpenApiSpecTimestamps(ConfigurationFile config) @@ -197,13 +217,10 @@ private void UpdateOpenApiSpecTimestamps(ConfigurationFile config) specFile.Refresh(); _openApiSpecLastModified[specFile.FullName] = specFile.LastWriteTimeUtc; } - - foreach (var childFile in apiConfig.Children) - { - childFile.Refresh(); - _apiMarkdownFilesLastModified[childFile.FullName] = childFile.LastWriteTimeUtc; - } } + + foreach (var (path, time) in CurrentApiMarkdownTimestamps(config)) + _apiMarkdownFilesLastModified[path] = time; } public async Task ReloadApiReferences(Cancel ctx) => await ReloadApiReferences(_generator.MarkdownStringRenderer, ctx); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 22c4dba606..21ecfd2057 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -98,9 +98,20 @@ public void ReadsContentSource() [Fact] public void StagingEnvironment_EnablesAssemblerApiExplorerFlag() { - var staging = Context.Configuration.Environments["staging"]; + AssertEnvironmentEnablesAssemblerApiExplorer("staging"); + } + + [Fact] + public void PreviewEnvironment_EnablesAssemblerApiExplorerFlag() + { + AssertEnvironmentEnablesAssemblerApiExplorer("preview"); + } + + private void AssertEnvironmentEnablesAssemblerApiExplorer(string environmentName) + { + var environment = Context.Configuration.Environments[environmentName]; - staging.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER") + environment.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER") .WhoseValue.Should().BeTrue(); } diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs new file mode 100644 index 0000000000..81014e95c4 --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs @@ -0,0 +1,147 @@ +// 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; +using Elastic.ApiExplorer.Model; +using Elastic.ApiExplorer.Supplemental; + +namespace Elastic.ApiExplorer.Tests.Supplemental; + +public class ApiSupplementalDiscoveryTests +{ + private const string Folder = "/docs/api/fixture"; + + [Fact] + public void Discover_MissingFolder_ReturnsEmpty() + { + var fs = new MockFileSystem(); + var folder = fs.DirectoryInfo.New("/docs/api/missing"); + + var result = ApiSupplementalDiscovery.Discover(folder, ["search"], ["search"]); + + result.Operations.Should().BeEmpty(); + result.Tags.Should().BeEmpty(); + result.Unmatched.Should().BeEmpty(); + result.Ignored.Should().BeEmpty(); + result.VersionSuffixed.Should().BeEmpty(); + } + + [Fact] + public void Discover_NullFolder_ReturnsEmpty() + { + var result = ApiSupplementalDiscovery.Discover(null, ["search"], ["search"]); + + result.Operations.Should().BeEmpty(); + } + + [Fact] + public void Discover_MatchesExactOperationId() + { + var folder = FolderWith( + "op-search.md", + "op-getAlertingHealth.md", + "op-getalertinghealth.md", + "op-cluster-health.md"); + + var result = ApiSupplementalDiscovery.Discover( + folder, + ["search", "getAlertingHealth", "cluster.health"], + []); + + result.Operations.Keys.Should().BeEquivalentTo("search", "getAlertingHealth"); + result.Unmatched.Select(f => f.Name).Should().BeEquivalentTo("op-getalertinghealth.md", "op-cluster-health.md"); + } + + [Fact] + public void Discover_MatchesTagUrlSlug() + { + var folder = FolderWith( + "tag-ml-anomaly.md", + "tag-health_report.md", + "tag-apm-agent-configuration.md"); + + var result = ApiSupplementalDiscovery.Discover( + folder, + [], + ["ml anomaly", "health_report", "APM agent configuration"]); + + result.Tags.Should().ContainKey("ml anomaly"); + result.Tags.Should().ContainKey("health_report"); + result.Tags.Should().ContainKey("APM agent configuration"); + result.Unmatched.Should().BeEmpty(); + } + + [Fact] + public void Discover_IgnoresNonConventionFiles() + { + var folder = FolderWith("random-notes.md", "getting-started.md", "op-search.md"); + + var result = ApiSupplementalDiscovery.Discover(folder, ["search"], []); + + result.Ignored.Select(f => f.Name).Should().BeEquivalentTo("random-notes.md", "getting-started.md"); + result.Operations.Should().ContainKey("search"); + } + + [Fact] + public void Discover_UnmatchedConventionFile_IsNotAnError() + { + var folder = FolderWith("op-does-not-exist.md"); + + var result = ApiSupplementalDiscovery.Discover(folder, ["search"], []); + + result.Unmatched.Should().ContainSingle(f => f.Name == "op-does-not-exist.md"); + result.Operations.Should().BeEmpty(); + } + + [Fact] + public void Discover_VersionSuffixedFile_IsClassifiedSeparately() + { + var folder = FolderWith("op-search.v8.md", "op-search.md"); + + var result = ApiSupplementalDiscovery.Discover(folder, ["search"], []); + + result.Operations.Should().ContainKey("search"); + result.VersionSuffixed.Should().ContainSingle(v => v.File.Name == "op-search.v8.md" && v.Name.VersionMajor == 8); + result.Unmatched.Should().BeEmpty(); + } + + [Fact] + public void Discover_TagSlugCollision_IsRecordedAndFileUnmatched() + { + var folder = FolderWith("tag-foo-bar.md"); + + var result = ApiSupplementalDiscovery.Discover(folder, [], ["foo bar", "foo-bar"]); + + result.TagSlugCollisions.Should().ContainSingle(c => c.Slug == "foo-bar"); + result.TagSlugCollisions[0].TagNames.Should().BeEquivalentTo("foo bar", "foo-bar"); + result.Tags.Should().BeEmpty(); + result.Unmatched.Should().ContainSingle(f => f.Name == "tag-foo-bar.md"); + } + + [Fact] + public async Task Discover_FixtureDocument_MatchesSearchAndDocsGet() + { + var folder = FolderWith("op-search.md", "op-docs-get.md", "tag-search.md", "random-notes.md"); + var path = Path.Combine(AppContext.BaseDirectory, "TestData", "api-explorer-fixture.json"); + var document = await OpenApiReader.Instance.ReadAsync(new System.IO.Abstractions.FileSystem().FileInfo.New(path)) + ?? throw new InvalidOperationException("Could not read fixture spec"); + + var result = ApiSupplementalDiscovery.Discover(folder, document); + + result.Operations.Should().ContainKey("search"); + result.Operations.Should().ContainKey("docs-get"); + result.Tags.Should().ContainKey("search"); + result.Ignored.Should().ContainSingle(f => f.Name == "random-notes.md"); + } + + private static System.IO.Abstractions.IDirectoryInfo FolderWith(params string[] fileNames) + { + var files = fileNames.ToDictionary( + name => $"{Folder}/{name}", + _ => new MockFileData("# supplemental")); + var fs = new MockFileSystem(files); + return fs.DirectoryInfo.New(Folder); + } +} diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs new file mode 100644 index 0000000000..d6affa2244 --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs @@ -0,0 +1,63 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using AwesomeAssertions; +using Elastic.ApiExplorer.Infrastructure; +using Elastic.ApiExplorer.Supplemental; + +namespace Elastic.ApiExplorer.Tests.Supplemental; + +public class ApiSupplementalNameTests +{ + [Theory] + [InlineData("op-search.md", ApiSupplementalKind.Operation, "search", null)] + [InlineData("op-getAlertingHealth.md", ApiSupplementalKind.Operation, "getAlertingHealth", null)] + [InlineData("op-search.v8.md", ApiSupplementalKind.Operation, "search", 8)] + [InlineData("tag-ml-anomaly.md", ApiSupplementalKind.Tag, "ml-anomaly", null)] + [InlineData("tag-health_report.md", ApiSupplementalKind.Tag, "health_report", null)] + [InlineData("tag-apm-agent-configuration.v9.md", ApiSupplementalKind.Tag, "apm-agent-configuration", 9)] + public void TryParse_ConventionFile_ReturnsKindStemAndVersion( + string fileName, ApiSupplementalKind kind, string stem, int? version) + { + ApiSupplementalName.TryParse(fileName, out var parsed).Should().BeTrue(); + parsed.Should().NotBeNull(); + parsed.Value.Kind.Should().Be(kind); + parsed.Value.Stem.Should().Be(stem); + parsed.Value.VersionMajor.Should().Be(version); + parsed.Value.IsVersionSuffixed.Should().Be(version is not null); + } + + [Theory] + [InlineData("random-notes.md")] + [InlineData("getting-started.md")] + [InlineData("index.md")] + [InlineData("op-.md")] + [InlineData("search.md")] + [InlineData("op-search.txt")] + public void TryParse_NonConventionFile_ReturnsFalse(string fileName) + { + ApiSupplementalName.TryParse(fileName, out var parsed).Should().BeFalse(); + parsed.Should().BeNull(); + ApiSupplementalName.IsConventionFileName(fileName).Should().BeFalse(); + } + + [Theory] + [InlineData("APM agent configuration", "apm-agent-configuration")] + [InlineData("health_report", "health_report")] + [InlineData("ml anomaly", "ml-anomaly")] + public void TagFileStem_UsesSharedTagSlug(string tagName, string expectedStem) + { + ApiSupplementalName.TagFileStem(tagName).Should().Be(expectedStem); + ApiUrlBuilder.TagSlug(tagName).Should().Be(expectedStem); + ApiUrlBuilder.TagMoniker(tagName).Should().Be($"endpoint-{expectedStem}"); + } + + [Fact] + public void TagSlug_EmptyName_IsUnknown() + { + ApiUrlBuilder.TagSlug("").Should().Be("unknown"); + ApiUrlBuilder.TagMoniker("").Should().Be("endpoint-unknown"); + ApiUrlBuilder.TagMoniker(null).Should().Be("endpoint-unknown"); + } +} diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/OpenApiGeneratorSupplementalTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/OpenApiGeneratorSupplementalTests.cs new file mode 100644 index 0000000000..08acc1614d --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/OpenApiGeneratorSupplementalTests.cs @@ -0,0 +1,46 @@ +// 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; +using Elastic.ApiExplorer.Supplemental; +using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.Toc; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elastic.ApiExplorer.Tests.Supplemental; + +public class OpenApiGeneratorSupplementalTests(ApiExplorerFixture fixture) : IClassFixture +{ + [Fact] + public void DiscoverSupplemental_MatchesFixtureFilesAndLeavesHtmlUnchanged() + { + var folder = "/docs/api/fixture"; + var fs = new MockFileSystem(new Dictionary + { + [$"{folder}/op-search.md"] = new("# search"), + [$"{folder}/tag-search.md"] = new("# tag"), + [$"{folder}/random-notes.md"] = new("# notes"), + [$"{folder}/op-nope.md"] = new("# unmatched") + }); + var apiConfig = new ResolvedApiConfiguration + { + ProductKey = "fixture", + Product = new Product { Id = "elasticsearch", DisplayName = "Elasticsearch" }, + SpecFileName = "api-explorer-fixture.json", + ApiContentDirectory = fs.DirectoryInfo.New(folder) + }; + + var generator = new OpenApiGenerator(NullLoggerFactory.Instance, fixture.Context, PassthroughMarkdownRenderer.Instance); + var result = generator.DiscoverSupplemental(fixture.Document, apiConfig); + + result.Operations.Should().ContainKey("search"); + result.Tags.Should().ContainKey("search"); + result.Ignored.Should().ContainSingle(f => f.Name == "random-notes.md"); + result.Unmatched.Should().ContainSingle(f => f.Name == "op-nope.md"); + + var navigation = generator.CreateNavigation("fixture", fixture.Document, apiConfig); + navigation.Should().NotBeNull(); + } +} diff --git a/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs b/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs index eb034e3a48..7307a04f44 100644 --- a/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs @@ -51,15 +51,26 @@ public void AssemblerApiExplorerEnabled_EnvironmentVariableOverridesYaml() [Fact] public void StagingEnvironment_EnablesAssemblerApiExplorer() + { + AssertEnvironmentEnablesAssemblerApiExplorer("staging"); + } + + [Fact] + public void PreviewEnvironment_EnablesAssemblerApiExplorer() + { + AssertEnvironmentEnablesAssemblerApiExplorer("preview"); + } + + private static void AssertEnvironmentEnablesAssemblerApiExplorer(string environmentName) { var config = AssemblyConfiguration.Create(new ConfigurationFileProvider(new TestLoggerFactory(null), new ConfigurationFileSystem())); - var staging = config.Environments["staging"]; + var environment = config.Environments[environmentName]; - staging.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER") + environment.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER") .WhoseValue.Should().BeTrue(); var features = new FeatureFlags([]); - foreach (var (key, value) in staging.FeatureFlags) + foreach (var (key, value) in environment.FeatureFlags) features.Set(key, value); features.AssemblerApiExplorerEnabled.Should().BeTrue(); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index 14bb6a8923..39f445bd6c 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs @@ -588,10 +588,97 @@ public void EmitsError_WhenChildPathEscapesApiKeyDirectory() config.ApiConfigurations!["elasticsearch"].Children.Should().BeEmpty(); } + [Fact] + public void EmitsError_WhenChildFileUsesSupplementalName() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = + [ + new ApiProductEntry + { + Spec = "elasticsearch-openapi.json", + Product = "elasticsearch", + Children = [new ApiEntryChild { File = "op-search.md" }] + } + ] + } + } + }; + + var (config, collector) = CreateConfiguration(docSetFile, extraMarkdownFiles: ["op-search.md"]); + + collector.Errors.Should().Be(1); + config.ApiConfigurations!["elasticsearch"].Children.Should().BeEmpty(); + } + + [Fact] + public void GetMarkdownPathsToExclude_IncludesChildrenAndSupplementalFiles() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = + [ + new ApiProductEntry + { + Spec = "elasticsearch-openapi.json", + Product = "elasticsearch", + Children = [new ApiEntryChild { File = "getting-started.md" }] + } + ] + } + } + }; + + var (config, collector) = CreateConfiguration( + docSetFile, + extraMarkdownFiles: ["op-search.md", "tag-documents.md", "random-notes.md"]); + + collector.Errors.Should().Be(0); + var docsRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs"); + var excluded = config.ApiConfigurations!["elasticsearch"] + .GetMarkdownPathsToExclude(docsRoot) + .ToArray(); + + excluded.Should().Contain("api/elasticsearch/getting-started.md"); + excluded.Should().Contain("api/elasticsearch/op-search.md"); + excluded.Should().Contain("api/elasticsearch/tag-documents.md"); + excluded.Should().NotContain("api/elasticsearch/random-notes.md"); + } + + [Fact] + public void ApiContentDirectory_IsSetToApiKeyFolder() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = [new ApiProductEntry { Spec = "elasticsearch-openapi.json", Product = "elasticsearch" }] + } + } + }; + + var (config, collector) = CreateConfiguration(docSetFile); + + collector.Errors.Should().Be(0); + config.ApiConfigurations!["elasticsearch"].ApiContentDirectory.Should().NotBeNull(); + config.ApiConfigurations["elasticsearch"].ApiContentDirectory!.Name.Should().Be("elasticsearch"); + } + private static readonly string[] DefaultProductIds = ["elasticsearch", "kibana"]; private static (ConfigurationFile Config, DiagnosticsCollector Collector) CreateConfiguration( - DocumentationSetFile docSet, string[]? extraProducts = null, bool withLocalSpecFile = true) + DocumentationSetFile docSet, string[]? extraProducts = null, bool withLocalSpecFile = true, string[]? extraMarkdownFiles = null) { var collector = new DiagnosticsCollector([]); var root = Paths.WorkingDirectoryRoot.FullName; @@ -604,6 +691,8 @@ private static (ConfigurationFile Config, DiagnosticsCollector Collector) Create }; if (withLocalSpecFile) files[Path.Join(root, "docs", "elasticsearch-openapi.json")] = new MockFileData("{}"); + foreach (var name in extraMarkdownFiles ?? []) + files[Path.Join(root, "docs", "api", "elasticsearch", name)] = new MockFileData("# extra"); var fileSystem = new MockFileSystem(files, root); var configPath = fileSystem.FileInfo.New(configFilePath); diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs index 2a8cb49247..131649b8a2 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -48,7 +48,11 @@ public void PhysicalDocsetFileCanBeDeserialized() docSet.Subs.Should().NotBeEmpty(); docSet.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder"); - docSet.Api.Should().BeNullOrEmpty("API declarations live in docs-content for assembler builds"); + docSet.Api.Should().ContainKey("elasticsearch"); + var apiEntry = docSet.Api!["elasticsearch"].SingleEntry; + apiEntry.Should().NotBeNull(); + apiEntry!.Spec.Should().Be("elasticsearch-openapi-docs.json"); + apiEntry.Product.Should().Be("elasticsearch"); docSet.TableOfContents.Should().NotBeEmpty(); From b5fcdecdc5c33b3cb669eb8ad4ce4023a0360bf1 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 11:09:52 +0200 Subject: [PATCH 02/13] Fix format warnings in API Explorer discovery tests Co-authored-by: Cursor --- .../AssemblerConfigurationTests.cs | 8 ++------ .../FeatureFlagsTests.cs | 8 ++------ .../PhysicalDocsetTests.cs | 4 ++-- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 21ecfd2057..17205421f3 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -96,16 +96,12 @@ public void ReadsContentSource() } [Fact] - public void StagingEnvironment_EnablesAssemblerApiExplorerFlag() - { + public void StagingEnvironment_EnablesAssemblerApiExplorerFlag() => AssertEnvironmentEnablesAssemblerApiExplorer("staging"); - } [Fact] - public void PreviewEnvironment_EnablesAssemblerApiExplorerFlag() - { + public void PreviewEnvironment_EnablesAssemblerApiExplorerFlag() => AssertEnvironmentEnablesAssemblerApiExplorer("preview"); - } private void AssertEnvironmentEnablesAssemblerApiExplorer(string environmentName) { diff --git a/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs b/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs index 7307a04f44..2b85421268 100644 --- a/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs @@ -50,16 +50,12 @@ public void AssemblerApiExplorerEnabled_EnvironmentVariableOverridesYaml() } [Fact] - public void StagingEnvironment_EnablesAssemblerApiExplorer() - { + public void StagingEnvironment_EnablesAssemblerApiExplorer() => AssertEnvironmentEnablesAssemblerApiExplorer("staging"); - } [Fact] - public void PreviewEnvironment_EnablesAssemblerApiExplorer() - { + public void PreviewEnvironment_EnablesAssemblerApiExplorer() => AssertEnvironmentEnablesAssemblerApiExplorer("preview"); - } private static void AssertEnvironmentEnablesAssemblerApiExplorer(string environmentName) { diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs index 131649b8a2..181074c801 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -49,9 +49,9 @@ public void PhysicalDocsetFileCanBeDeserialized() docSet.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder"); docSet.Api.Should().ContainKey("elasticsearch"); - var apiEntry = docSet.Api!["elasticsearch"].SingleEntry; + var apiEntry = docSet.Api["elasticsearch"].SingleEntry; apiEntry.Should().NotBeNull(); - apiEntry!.Spec.Should().Be("elasticsearch-openapi-docs.json"); + apiEntry.Spec.Should().Be("elasticsearch-openapi-docs.json"); apiEntry.Product.Should().Be("elasticsearch"); docSet.TableOfContents.Should().NotBeEmpty(); From c59deaac5185fe8fa663cfb05ab7a25ee7c8e96b Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 11:28:35 +0200 Subject: [PATCH 03/13] Simplify API supplemental discovery and cache HTML exclude paths Co-authored-by: Cursor --- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 9 +- .../Supplemental/ApiSupplementalDiscovery.cs | 109 ++++++++---------- .../Supplemental/ApiSupplementalName.cs | 11 +- .../Builder/ConfigurationFile.cs | 11 +- .../Toc/ApiConfiguration.cs | 5 +- .../DocumentationGenerator.cs | 27 +++-- .../Http/ReloadableGeneratorState.cs | 2 +- .../Supplemental/ApiSupplementalNameTests.cs | 16 +-- 8 files changed, 79 insertions(+), 111 deletions(-) diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 22929c1323..5f8d664a1f 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -255,14 +255,7 @@ internal ApiSupplementalDiscoveryResult DiscoverSupplemental( OpenApiDocument openApiDocument, ResolvedApiConfiguration? apiConfig) { - var folder = apiConfig?.ApiContentDirectory; - if (folder is null && apiConfig is not null) - { - folder = context.ReadFileSystem.DirectoryInfo.New( - Path.Join(context.DocumentationSourceDirectory.FullName, "api", apiConfig.ProductKey)); - } - - var result = ApiSupplementalDiscovery.Discover(folder, openApiDocument); + var result = ApiSupplementalDiscovery.Discover(apiConfig?.ApiContentDirectory, openApiDocument); if (result.Operations.Count == 0 && result.Tags.Count == 0 && result.Unmatched.Count == 0) return result; diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs index 9e45650c30..3f32ef00e9 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.ApiExplorer.Infrastructure; using Microsoft.OpenApi; namespace Elastic.ApiExplorer.Supplemental; @@ -13,16 +14,6 @@ public sealed record ApiSupplementalVersionedFile(IFileInfo File, ApiSupplementa public sealed class ApiSupplementalDiscoveryResult { - public static ApiSupplementalDiscoveryResult Empty { get; } = new() - { - Operations = new Dictionary(), - Tags = new Dictionary(), - Unmatched = [], - Ignored = [], - VersionSuffixed = [], - TagSlugCollisions = [] - }; - public required IReadOnlyDictionary Operations { get; init; } public required IReadOnlyDictionary Tags { get; init; } public required IReadOnlyList Unmatched { get; init; } @@ -42,23 +33,25 @@ public static ApiSupplementalDiscoveryResult Discover( IReadOnlyCollection operationIds, IReadOnlyCollection tagNames) { - var collisions = TagCollisions(tagNames); - var collidingSlugs = collisions.Select(c => c.Slug).ToHashSet(StringComparer.Ordinal); - var tagBySlug = UniqueTagBySlug(tagNames, collidingSlugs); - var operationSet = operationIds.ToHashSet(StringComparer.Ordinal); + var (tagBySlug, collisions) = IndexTags(tagNames); + return MatchFiles(folder, operationIds.ToHashSet(StringComparer.Ordinal), tagBySlug, collisions); + } + + public static ApiSupplementalDiscoveryResult Discover(IDirectoryInfo? folder, OpenApiDocument document) + { + var (operationIds, tagNames) = CollectEntities(document); + var (tagBySlug, collisions) = IndexTags(tagNames); + return MatchFiles(folder, operationIds, tagBySlug, collisions); + } + private static ApiSupplementalDiscoveryResult MatchFiles( + IDirectoryInfo? folder, + HashSet operationIds, + Dictionary tagBySlug, + IReadOnlyList collisions) + { if (folder is null || !folder.Exists) - { - return new ApiSupplementalDiscoveryResult - { - Operations = new Dictionary(), - Tags = new Dictionary(), - Unmatched = [], - Ignored = [], - VersionSuffixed = [], - TagSlugCollisions = collisions - }; - } + return NoFiles(collisions); var operations = new Dictionary(StringComparer.Ordinal); var tags = new Dictionary(StringComparer.Ordinal); @@ -68,13 +61,12 @@ public static ApiSupplementalDiscoveryResult Discover( foreach (var file in folder.EnumerateFiles("*.md")) { - if (!ApiSupplementalName.TryParse(file.Name, out var parsed)) + if (!ApiSupplementalName.TryParse(file.Name, out var name)) { ignored.Add(file); continue; } - var name = parsed.Value; if (name.IsVersionSuffixed) { versionSuffixed.Add(new ApiSupplementalVersionedFile(file, name)); @@ -83,18 +75,12 @@ public static ApiSupplementalDiscoveryResult Discover( if (name.Kind == ApiSupplementalKind.Operation) { - if (operationSet.Contains(name.Stem) && operations.TryAdd(name.Stem, file)) + if (operationIds.Contains(name.Stem) && operations.TryAdd(name.Stem, file)) continue; unmatched.Add(file); continue; } - if (collidingSlugs.Contains(name.Stem)) - { - unmatched.Add(file); - continue; - } - if (tagBySlug.TryGetValue(name.Stem, out var tagName) && tags.TryAdd(tagName, file)) continue; @@ -112,16 +98,7 @@ public static ApiSupplementalDiscoveryResult Discover( }; } - public static ApiSupplementalDiscoveryResult Discover(IDirectoryInfo? folder, OpenApiDocument document) - { - CollectEntities(document, out var operationIds, out var tagNames); - return Discover(folder, operationIds, tagNames); - } - - internal static void CollectEntities( - OpenApiDocument document, - out List operationIds, - out List tagNames) + private static (HashSet OperationIds, HashSet TagNames) CollectEntities(OpenApiDocument document) { var operations = new HashSet(StringComparer.Ordinal); var tags = new HashSet(StringComparer.Ordinal); @@ -157,42 +134,46 @@ internal static void CollectEntities( } } - operationIds = [.. operations]; - tagNames = [.. tags]; + return (operations, tags); } - private static IReadOnlyList TagCollisions(IReadOnlyCollection tagNames) + private static (Dictionary UniqueBySlug, IReadOnlyList Collisions) IndexTags( + IReadOnlyCollection tagNames) { var bySlug = new Dictionary>(StringComparer.Ordinal); foreach (var tagName in tagNames) { - var slug = ApiSupplementalName.TagFileStem(tagName); + var slug = ApiUrlBuilder.TagSlug(tagName); if (!bySlug.TryGetValue(slug, out var names)) { names = []; bySlug[slug] = names; } + if (!names.Contains(tagName, StringComparer.Ordinal)) names.Add(tagName); } - return [.. bySlug - .Where(kv => kv.Value.Count > 1) - .Select(kv => new TagSlugCollision(kv.Key, kv.Value))]; - } - - private static Dictionary UniqueTagBySlug( - IReadOnlyCollection tagNames, - HashSet collidingSlugs) - { - var map = new Dictionary(StringComparer.Ordinal); - foreach (var tagName in tagNames) + var unique = new Dictionary(StringComparer.Ordinal); + var collisions = new List(); + foreach (var (slug, names) in bySlug) { - var slug = ApiSupplementalName.TagFileStem(tagName); - if (collidingSlugs.Contains(slug)) - continue; - _ = map.TryAdd(slug, tagName); + if (names.Count == 1) + unique[slug] = names[0]; + else + collisions.Add(new TagSlugCollision(slug, names)); } - return map; + + return (unique, collisions); } + + private static ApiSupplementalDiscoveryResult NoFiles(IReadOnlyList collisions) => new() + { + Operations = new Dictionary(), + Tags = new Dictionary(), + Unmatched = [], + Ignored = [], + VersionSuffixed = [], + TagSlugCollisions = collisions + }; } diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs index fc4d5717ed..991cffe1aa 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs @@ -2,9 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; -using Elastic.ApiExplorer.Infrastructure; namespace Elastic.ApiExplorer.Supplemental; @@ -28,11 +26,9 @@ public readonly record struct ApiSupplementalFileName( /// public static partial class ApiSupplementalName { - public static bool IsConventionFileName(string fileName) => TryParse(fileName, out _); - - public static bool TryParse(string fileName, [NotNullWhen(true)] out ApiSupplementalFileName? parsed) + public static bool TryParse(string fileName, out ApiSupplementalFileName parsed) { - parsed = null; + parsed = default; var match = FileNamePattern().Match(fileName); if (!match.Success) return false; @@ -56,9 +52,6 @@ public static bool TryParse(string fileName, [NotNullWhen(true)] out ApiSuppleme return true; } - /// File stem for a tag, identical to the URL slug without the endpoint- prefix. - public static string TagFileStem(string tagName) => ApiUrlBuilder.TagSlug(tagName); - [GeneratedRegex(@"^(op|tag)-(.+?)(?:\.v(\d+))?\.md$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex FileNamePattern(); } diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index 41cc28a68f..8cf016e473 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -563,9 +563,9 @@ private static bool IsValidProductId(string product) => repository = candidate; } - var children = ResolveApiChildren(productKey, entry.Children, context); var apiContentDirectory = context.ReadFileSystem.DirectoryInfo.New( Path.Join(context.DocumentationSourceDirectory.FullName, "api", productKey)); + var children = ResolveApiChildren(productKey, entry.Children, context, apiContentDirectory); return new ResolvedApiConfiguration { @@ -581,14 +581,15 @@ private static bool IsValidProductId(string product) => /// Children resolve only under 'api/<key>/'; escaping paths and symlinks are rejected the /// same way branding image paths are (see ). - private static List ResolveApiChildren(string productKey, List children, IDocumentationSetContext context) + private static List ResolveApiChildren( + string productKey, + List children, + IDocumentationSetContext context, + IDirectoryInfo childrenDirectory) { if (children.Count == 0) return []; - var childrenDirectory = context.ReadFileSystem.DirectoryInfo.New( - Path.Join(context.DocumentationSourceDirectory.FullName, "api", productKey)); - var resolved = new List(); foreach (var child in children) { diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs index 7a71acf8a8..ebdd052e78 100644 --- a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs @@ -210,10 +210,7 @@ public IEnumerable GetMarkdownPathsToExclude(string documentationSourceD yield return relative; } - if (ApiContentDirectory is not { Exists: true } dir) - yield break; - - foreach (var file in dir.EnumerateFiles("*.md")) + foreach (var file in EnumerateApiMarkdownFiles()) { if (!IsSupplementalFileName(file.Name)) continue; diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 9853605938..784b9c35f2 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -48,6 +48,7 @@ public partial class DocumentationGenerator private readonly IDocumentationFileExporter _documentationFileExporter; private readonly IMarkdownExporter[] _markdownExporters; private readonly IDocumentInferrerService _documentInferrer; + private HashSet? _apiMarkdownExcludePaths; private HtmlWriter HtmlWriter { get; } public DocumentationSet DocumentationSet { get; } @@ -502,26 +503,32 @@ public async Task RenderLayout(MarkdownFile markdown, Cancel ctx) } /// - /// Checks if a file path is registered as an explicit children: page in any API - /// configuration. These files render via the API pipeline rather than normal HTML generation. + /// True when the file is an API children: page or a convention supplemental file. + /// Those files skip the normal HTML pipeline. /// private bool IsApiMarkdownFile(string relativePath) { var normalized = relativePath.Replace(Path.DirectorySeparatorChar, '/'); + return ApiMarkdownExcludePaths.Contains(normalized); + } + + private HashSet ApiMarkdownExcludePaths => + _apiMarkdownExcludePaths ??= BuildApiMarkdownExcludePaths(); - if (Context.Configuration.ApiConfigurations == null) - return false; + private HashSet BuildApiMarkdownExcludePaths() + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + if (Context.Configuration.ApiConfigurations is null) + return set; + var docsRoot = Context.DocumentationSourceDirectory.FullName; foreach (var apiConfig in Context.Configuration.ApiConfigurations.Values) { - foreach (var childPath in apiConfig.GetMarkdownPathsToExclude(Context.DocumentationSourceDirectory.FullName)) - { - if (string.Equals(normalized, childPath, StringComparison.OrdinalIgnoreCase)) - return true; - } + foreach (var path in apiConfig.GetMarkdownPathsToExclude(docsRoot)) + _ = set.Add(path); } - return false; + return set; } } diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 1588de600f..0dbb7b0ba6 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -60,7 +60,7 @@ 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 + // Track api//*.md modification times so serve reloads on overlay edits. private readonly Dictionary _apiMarkdownFilesLastModified = []; private volatile bool _apiReferencesStale = true; diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs index d6affa2244..1521604e44 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs @@ -21,11 +21,10 @@ public void TryParse_ConventionFile_ReturnsKindStemAndVersion( string fileName, ApiSupplementalKind kind, string stem, int? version) { ApiSupplementalName.TryParse(fileName, out var parsed).Should().BeTrue(); - parsed.Should().NotBeNull(); - parsed.Value.Kind.Should().Be(kind); - parsed.Value.Stem.Should().Be(stem); - parsed.Value.VersionMajor.Should().Be(version); - parsed.Value.IsVersionSuffixed.Should().Be(version is not null); + parsed.Kind.Should().Be(kind); + parsed.Stem.Should().Be(stem); + parsed.VersionMajor.Should().Be(version); + parsed.IsVersionSuffixed.Should().Be(version is not null); } [Theory] @@ -37,18 +36,15 @@ public void TryParse_ConventionFile_ReturnsKindStemAndVersion( [InlineData("op-search.txt")] public void TryParse_NonConventionFile_ReturnsFalse(string fileName) { - ApiSupplementalName.TryParse(fileName, out var parsed).Should().BeFalse(); - parsed.Should().BeNull(); - ApiSupplementalName.IsConventionFileName(fileName).Should().BeFalse(); + ApiSupplementalName.TryParse(fileName, out _).Should().BeFalse(); } [Theory] [InlineData("APM agent configuration", "apm-agent-configuration")] [InlineData("health_report", "health_report")] [InlineData("ml anomaly", "ml-anomaly")] - public void TagFileStem_UsesSharedTagSlug(string tagName, string expectedStem) + public void TagSlug_MatchesExpectedFileStem(string tagName, string expectedStem) { - ApiSupplementalName.TagFileStem(tagName).Should().Be(expectedStem); ApiUrlBuilder.TagSlug(tagName).Should().Be(expectedStem); ApiUrlBuilder.TagMoniker(tagName).Should().Be($"endpoint-{expectedStem}"); } From 1de202ec6560e500bd45ae8d606d605c14d78dd7 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 11:28:40 +0200 Subject: [PATCH 04/13] Remove local elasticsearch API from the docs-builder docset Assembler preview includes this repo's docs even when skip is true, so the local elasticsearch key collided with docs-content. Co-authored-by: Cursor --- docs/_docset.yml | 8 -------- docs/api/elasticsearch/op-async-search-get.md | 3 --- docs/data/openapi/api-explorer.md | 2 -- .../PhysicalDocsetTests.cs | 6 +----- 4 files changed, 1 insertion(+), 18 deletions(-) delete mode 100644 docs/api/elasticsearch/op-async-search-get.md diff --git a/docs/_docset.yml b/docs/_docset.yml index 3e46e25f97..0f27dcbb98 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -38,14 +38,6 @@ subs: features: primary-nav: false -# Isolated `docs-builder serve` / `build` only. This repo is `skip: true` in assembler.yml, -# so assembler does not publish these pages. The local spec overrides remote resolution. -# Run serve without `--watch`, then open /api/doc/elasticsearch/ -api: - elasticsearch: - - spec: elasticsearch-openapi-docs.json - product: elasticsearch - cta: docs-builder: button: diff --git a/docs/api/elasticsearch/op-async-search-get.md b/docs/api/elasticsearch/op-async-search-get.md deleted file mode 100644 index cf79a9f9b9..0000000000 --- a/docs/api/elasticsearch/op-async-search-get.md +++ /dev/null @@ -1,3 +0,0 @@ -This file is a local fixture for docs-builder development. - -Discovery should associate it with the `async-search-get` operation. diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index bd5ea2b890..6995ccde33 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -236,8 +236,6 @@ The API Explorer generates documentation in these scenarios: API generation is skipped when running `docs-builder serve --watch`. This is a performance optimization for `dotnet watch` workflows. Run `serve` without `--watch` to include API docs in your local preview. ::: -This repository's own `_docset.yml` declares a local `elasticsearch` API that reads `elasticsearch-openapi-docs.json`. Use that entry to preview ApiExplorer and supplemental files during isolated `docs-builder serve`. Assembler skips this repo (`skip: true` in `assembler.yml`), so those pages are not published. - ## Link to API pages in navigation You can reference API pages in your `toc.yml` or `docset.yml` navigation using cross-link syntax: diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs index 181074c801..2a8cb49247 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -48,11 +48,7 @@ public void PhysicalDocsetFileCanBeDeserialized() docSet.Subs.Should().NotBeEmpty(); docSet.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder"); - docSet.Api.Should().ContainKey("elasticsearch"); - var apiEntry = docSet.Api["elasticsearch"].SingleEntry; - apiEntry.Should().NotBeNull(); - apiEntry.Spec.Should().Be("elasticsearch-openapi-docs.json"); - apiEntry.Product.Should().Be("elasticsearch"); + docSet.Api.Should().BeNullOrEmpty("API declarations live in docs-content for assembler builds"); docSet.TableOfContents.Should().NotBeEmpty(); From aaea2e0583c08a26d62825c9d9d05a97d44831e8 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 11:31:20 +0200 Subject: [PATCH 05/13] Use an auto-property for cached API markdown exclude paths Co-authored-by: Cursor --- src/Elastic.Markdown/DocumentationGenerator.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 784b9c35f2..2d3a37558a 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -48,12 +48,12 @@ public partial class DocumentationGenerator private readonly IDocumentationFileExporter _documentationFileExporter; private readonly IMarkdownExporter[] _markdownExporters; private readonly IDocumentInferrerService _documentInferrer; - private HashSet? _apiMarkdownExcludePaths; private HtmlWriter HtmlWriter { get; } public DocumentationSet DocumentationSet { get; } public BuildContext Context { get; } public IMarkdownStringRenderer MarkdownStringRenderer => HtmlWriter; + private HashSet ApiMarkdownExcludePaths { get; } public DocumentationGenerator( DocumentationSet docSet, @@ -77,6 +77,7 @@ public DocumentationGenerator( DocumentationSet = docSet; PositionalNavigation = positionalNavigation ?? docSet; Context = docSet.Context; + ApiMarkdownExcludePaths = BuildApiMarkdownExcludePaths(); // Use the provided inferrer or create a default one _documentInferrer = documentInferrer ?? new DocumentInferrerService( @@ -512,9 +513,6 @@ private bool IsApiMarkdownFile(string relativePath) return ApiMarkdownExcludePaths.Contains(normalized); } - private HashSet ApiMarkdownExcludePaths => - _apiMarkdownExcludePaths ??= BuildApiMarkdownExcludePaths(); - private HashSet BuildApiMarkdownExcludePaths() { var set = new HashSet(StringComparer.OrdinalIgnoreCase); From 9df27258a5524e5babc491d0bf15a805795e3474 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 11:38:56 +0200 Subject: [PATCH 06/13] Restore local Elasticsearch API fixture under a unique key Assembler preview still builds this checkout, so the URL key cannot be elasticsearch. Co-authored-by: Cursor --- docs/_docset.yml | 9 +++++++++ .../docs-builder-elasticsearch/op-async-search-get.md | 7 +++++++ docs/data/openapi/api-explorer.md | 2 ++ .../PhysicalDocsetTests.cs | 6 +++++- 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 docs/api/docs-builder-elasticsearch/op-async-search-get.md diff --git a/docs/_docset.yml b/docs/_docset.yml index 0f27dcbb98..45e7dbb1ed 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -38,6 +38,15 @@ subs: features: primary-nav: false +# Local fixture for ApiExplorer and supplemental files. The URL key must not collide +# with docs-content's `elasticsearch` key: assembler preview still builds this checkout +# even though the repo is `skip: true` in assembler.yml. +# Isolated serve (no `--watch`): /api/doc/docs-builder-elasticsearch/ +api: + docs-builder-elasticsearch: + - spec: elasticsearch-openapi-docs.json + product: elasticsearch + cta: docs-builder: button: diff --git a/docs/api/docs-builder-elasticsearch/op-async-search-get.md b/docs/api/docs-builder-elasticsearch/op-async-search-get.md new file mode 100644 index 0000000000..fee2f71fd4 --- /dev/null +++ b/docs/api/docs-builder-elasticsearch/op-async-search-get.md @@ -0,0 +1,7 @@ +--- +title: Async search get supplemental fixture +--- + +This file is a local fixture for docs-builder development. + +Discovery should associate it with the `async-search-get` operation. diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index 6995ccde33..cbbc2d48f5 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -236,6 +236,8 @@ The API Explorer generates documentation in these scenarios: API generation is skipped when running `docs-builder serve --watch`. This is a performance optimization for `dotnet watch` workflows. Run `serve` without `--watch` to include API docs in your local preview. ::: +This repository's own `_docset.yml` declares a local `docs-builder-elasticsearch` API that reads `elasticsearch-openapi-docs.json`. Use that entry to preview ApiExplorer and supplemental files during isolated `docs-builder serve` (open `/api/doc/docs-builder-elasticsearch/`). The key is not `elasticsearch`, so assembler preview does not collide with docs-content. + ## Link to API pages in navigation You can reference API pages in your `toc.yml` or `docset.yml` navigation using cross-link syntax: diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs index 2a8cb49247..c810281b8f 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -48,7 +48,11 @@ public void PhysicalDocsetFileCanBeDeserialized() docSet.Subs.Should().NotBeEmpty(); docSet.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder"); - docSet.Api.Should().BeNullOrEmpty("API declarations live in docs-content for assembler builds"); + docSet.Api.Should().ContainKey("docs-builder-elasticsearch"); + var apiEntry = docSet.Api["docs-builder-elasticsearch"].SingleEntry; + apiEntry.Should().NotBeNull(); + apiEntry.Spec.Should().Be("elasticsearch-openapi-docs.json"); + apiEntry.Product.Should().Be("elasticsearch"); docSet.TableOfContents.Should().NotBeEmpty(); From 093cc9b10688a2396a82d733ffcd124b6bfb5076 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 11:41:13 +0200 Subject: [PATCH 07/13] fix: collect inline operation tag names (per review by @github-actions) Co-authored-by: Cursor --- .../Supplemental/ApiSupplementalDiscovery.cs | 5 ++- .../ApiSupplementalDiscoveryTests.cs | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs index 3f32ef00e9..3274231572 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs @@ -127,7 +127,7 @@ private static (HashSet OperationIds, HashSet TagNames) CollectE foreach (var tagRef in operation.Tags) { - var name = tagRef.Reference?.Id; + var name = OperationTagName(tagRef); if (!string.IsNullOrEmpty(name)) _ = tags.Add(name); } @@ -137,6 +137,9 @@ private static (HashSet OperationIds, HashSet TagNames) CollectE return (operations, tags); } + private static string? OperationTagName(OpenApiTagReference tagRef) => + !string.IsNullOrEmpty(tagRef.Name) ? tagRef.Name : tagRef.Reference?.Id; + private static (Dictionary UniqueBySlug, IReadOnlyList Collisions) IndexTags( IReadOnlyCollection tagNames) { diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs index 81014e95c4..7372e4e835 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs @@ -136,6 +136,37 @@ public async Task Discover_FixtureDocument_MatchesSearchAndDocsGet() result.Ignored.Should().ContainSingle(f => f.Name == "random-notes.md"); } + [Fact] + public async Task Discover_InlineOperationTagsWithoutDocumentTags_MatchTagFile() + { + var folder = FolderWith("tag-search.md"); + var specJson = /*lang=json,strict*/ """ + { + "openapi": "3.0.3", + "info": { "title": "t", "version": "1" }, + "paths": { + "/search": { + "get": { + "operationId": "search", + "tags": ["search"] + } + } + } + } + """; + var fs = new MockFileSystem(new Dictionary + { + ["/spec.json"] = new MockFileData(specJson) + }); + var document = await OpenApiReader.Instance.ReadAsync(fs.FileInfo.New("/spec.json")) + ?? throw new InvalidOperationException("Could not read spec"); + + var result = ApiSupplementalDiscovery.Discover(folder, document); + + result.Tags.Should().ContainKey("search"); + result.Unmatched.Should().BeEmpty(); + } + private static System.IO.Abstractions.IDirectoryInfo FolderWith(params string[] fileNames) { var files = fileNames.ToDictionary( From a189daa0a4c6335858226fd50865aaa1d78046d8 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 12:04:44 +0200 Subject: [PATCH 08/13] Rename the local Elasticsearch spec to elasticsearch.json The CloudFront version index keys specs by basename, and elasticsearch.json is the published name. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- docs/{elasticsearch-openapi-docs.json => elasticsearch.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{elasticsearch-openapi-docs.json => elasticsearch.json} (100%) diff --git a/docs/elasticsearch-openapi-docs.json b/docs/elasticsearch.json similarity index 100% rename from docs/elasticsearch-openapi-docs.json rename to docs/elasticsearch.json From 6e0b1d44f92d8c029ffd235e29ae93ac75cc1f8e Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 12:08:13 +0200 Subject: [PATCH 09/13] fix: reject only top-level op-/tag- children (per review by @github-actions) Co-authored-by: Cursor --- .../Builder/ConfigurationFile.cs | 7 ++++- .../ApiConfigurationTests.cs | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index 8cf016e473..94f71f9aaf 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -624,7 +624,7 @@ private static List ResolveApiChildren( continue; } - if (ResolvedApiConfiguration.IsSupplementalFileName(childFile.Name)) + if (IsTopLevelSupplementalChild(childFile, childrenDirectory)) { context.EmitError(context.ConfigurationPath, $"Child page '{child.File}' for API '{productKey}' uses a supplemental file name (op-*.md / tag-*.md). Those files are auto-discovered and cannot be listed under children:."); @@ -637,6 +637,11 @@ private static List ResolveApiChildren( return resolved; } + private static bool IsTopLevelSupplementalChild(IFileInfo childFile, IDirectoryInfo apiDirectory) => + childFile.Directory is not null + && string.Equals(childFile.Directory.FullName, apiDirectory.FullName, StringComparison.OrdinalIgnoreCase) + && ResolvedApiConfiguration.IsSupplementalFileName(childFile.Name); + private static CrossLinkEntry? ParseCrossLinkEntry(string raw, DocSetRegistry docsetRegistry, IFileInfo configPath, IDocumentationContext context) { DocSetRegistry entryRegistry; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index 39f445bd6c..862882d862 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs @@ -616,6 +616,34 @@ public void EmitsError_WhenChildFileUsesSupplementalName() config.ApiConfigurations!["elasticsearch"].Children.Should().BeEmpty(); } + [Fact] + public void AcceptsNestedChildWhoseBasenameLooksSupplemental() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = + [ + new ApiProductEntry + { + Spec = "elasticsearch-openapi.json", + Product = "elasticsearch", + Children = [new ApiEntryChild { File = "guides/op-overview.md" }] + } + ] + } + } + }; + + var (config, collector) = CreateConfiguration(docSetFile, extraMarkdownFiles: ["guides/op-overview.md"]); + + collector.Errors.Should().Be(0); + config.ApiConfigurations!["elasticsearch"].Children.Should().ContainSingle(f => f.Name == "op-overview.md"); + } + [Fact] public void GetMarkdownPathsToExclude_IncludesChildrenAndSupplementalFiles() { From e708a5a1a09097355bdc71651d031ef04df662a0 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 12:15:47 +0200 Subject: [PATCH 10/13] fix: mark API refs stale on content-only reloads (per review by @github-actions) Co-authored-by: Cursor --- src/tooling/docs-builder/Http/ReloadableGeneratorState.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 0dbb7b0ba6..1e579ac644 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -71,8 +71,13 @@ public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true) { // Content-only changes (e.g. .md edits) don't need a full rebuild: // RenderLayout -> ParseFullAsync reads fresh content from disk on each request. + // API overlay files are an exception: they are baked into generated HTML, so mark + // refs stale and let EnsureApiReferencesAsync re-check timestamps on the next /api request. if (!reloadConfiguration && _cachedCrossLinks is not null) + { + _apiReferencesStale = true; return; + } SourcePath.Refresh(); OutputPath.Refresh(); From 8e3dbd149ecb2bdd476e73543afe2dc204a3d0f1 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 12:23:50 +0200 Subject: [PATCH 11/13] Point the local Elasticsearch API fixture at the spec publisher The version index has no elastic/docs-builder entry, so --strict failed. Look up elastic/elasticsearch-specification / elasticsearch.json like docs-content. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- docs/_docset.yml | 7 +++++-- docs/api/docs-builder-elasticsearch/op-async-search-get.md | 4 +--- docs/data/openapi/api-explorer.md | 2 +- .../OpenApiGeneratorCurrentSpecResolutionTests.cs | 2 +- .../OpenApiGeneratorMultiVersionTests.cs | 2 +- tests/Elastic.ApiExplorer.Tests/ReaderTests.cs | 2 +- .../PhysicalDocsetTests.cs | 3 ++- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/_docset.yml b/docs/_docset.yml index 45e7dbb1ed..354382c7f5 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -41,11 +41,14 @@ features: # Local fixture for ApiExplorer and supplemental files. The URL key must not collide # with docs-content's `elasticsearch` key: assembler preview still builds this checkout # even though the repo is `skip: true` in assembler.yml. -# Isolated serve (no `--watch`): /api/doc/docs-builder-elasticsearch/ +# `repository:` matches the version-index publisher; without it, lookup uses this +# checkout (`elastic/docs-builder`) and `--strict` fails. Isolated serve (no +# `--watch`): /api/doc/docs-builder-elasticsearch/ api: docs-builder-elasticsearch: - - spec: elasticsearch-openapi-docs.json + - spec: elasticsearch.json product: elasticsearch + repository: elastic/elasticsearch-specification cta: docs-builder: diff --git a/docs/api/docs-builder-elasticsearch/op-async-search-get.md b/docs/api/docs-builder-elasticsearch/op-async-search-get.md index fee2f71fd4..fc77406fa1 100644 --- a/docs/api/docs-builder-elasticsearch/op-async-search-get.md +++ b/docs/api/docs-builder-elasticsearch/op-async-search-get.md @@ -1,6 +1,4 @@ ---- -title: Async search get supplemental fixture ---- +# Async search get supplemental fixture This file is a local fixture for docs-builder development. diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index cbbc2d48f5..fbf12daf8f 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -236,7 +236,7 @@ The API Explorer generates documentation in these scenarios: API generation is skipped when running `docs-builder serve --watch`. This is a performance optimization for `dotnet watch` workflows. Run `serve` without `--watch` to include API docs in your local preview. ::: -This repository's own `_docset.yml` declares a local `docs-builder-elasticsearch` API that reads `elasticsearch-openapi-docs.json`. Use that entry to preview ApiExplorer and supplemental files during isolated `docs-builder serve` (open `/api/doc/docs-builder-elasticsearch/`). The key is not `elasticsearch`, so assembler preview does not collide with docs-content. +This repository's own `_docset.yml` declares a local `docs-builder-elasticsearch` API that reads `elasticsearch.json` and sets `repository: elastic/elasticsearch-specification`. Use that entry to preview ApiExplorer and supplemental files during isolated `docs-builder serve` (open `/api/doc/docs-builder-elasticsearch/`). The key is not `elasticsearch`, so assembler preview does not collide with docs-content. ## Link to API pages in navigation diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs index 6bad9da9f8..a7e8404e7f 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs @@ -57,7 +57,7 @@ public async Task ResolveDocumentsForProduct_VersionlessLocalSpec_RendersLocalFi }; var context = CreateContext(collector, versionless, products); var localFile = new FileSystem().FileInfo.New( - Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json")); + Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch.json")); var expectedDocument = SpecDocument(); var reader = A.Fake(); A.CallTo(() => reader.ReadAsync(localFile)).Returns(expectedDocument); diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs index 7eff1c5823..1f85d086fa 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs @@ -137,7 +137,7 @@ public async Task ResolveDocumentsForProduct_LocalMainAndRemoteHistoricalVersion var stack = TestHelpers.CreateStackVersionsConfiguration(currentMajor: 9); var product = TestHelpers.CreateProduct("elasticsearch", stack.GetVersioningSystem(VersioningSystemId.Stack)); var context = CreateContext(collector, stack, ProductsFor(product), GitForElasticsearch()); - var localFile = new FileSystem().FileInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json")); + var localFile = new FileSystem().FileInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch.json")); var localDocument = SpecDocument("Elasticsearch local main"); using var versionIndexClient = new VersionIndexClient(BaseUri, MultiVersionHandler(), sleep: (_, _) => Task.CompletedTask); var reader = A.Fake(); diff --git a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs index 88672a7060..635386e235 100644 --- a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs @@ -21,7 +21,7 @@ public class ReaderTests private static IFileInfo LocalSpecFile() { var fileSystem = new FileSystem(); - var path = fileSystem.Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json"); + var path = fileSystem.Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch.json"); return fileSystem.FileInfo.New(path); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs index c810281b8f..3a8a9d67dd 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -51,8 +51,9 @@ public void PhysicalDocsetFileCanBeDeserialized() docSet.Api.Should().ContainKey("docs-builder-elasticsearch"); var apiEntry = docSet.Api["docs-builder-elasticsearch"].SingleEntry; apiEntry.Should().NotBeNull(); - apiEntry.Spec.Should().Be("elasticsearch-openapi-docs.json"); + apiEntry.Spec.Should().Be("elasticsearch.json"); apiEntry.Product.Should().Be("elasticsearch"); + apiEntry.Repository.Should().Be("elastic/elasticsearch-specification"); docSet.TableOfContents.Should().NotBeEmpty(); From 901b828414a09711e697eb483cbb7f3f1ec4ec26 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 13:32:06 +0200 Subject: [PATCH 12/13] fix: watch nested API children markdown for serve reload (per review by @github-actions) Co-authored-by: Cursor --- src/tooling/docs-builder/Http/ReloadableGeneratorState.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 1e579ac644..77428c8ad4 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -60,7 +60,7 @@ bool isWatchBuild // Track OpenAPI spec file modification times to detect changes private readonly Dictionary _openApiSpecLastModified = []; - // Track api//*.md modification times so serve reloads on overlay edits. + // Track API markdown modification times so serve reloads on overlay and children: edits. private readonly Dictionary _apiMarkdownFilesLastModified = []; private volatile bool _apiReferencesStale = true; @@ -197,7 +197,7 @@ private static Dictionary CurrentApiMarkdownTimestamps(C foreach (var apiConfig in config.ApiConfigurations.Values) { - foreach (var file in apiConfig.EnumerateApiMarkdownFiles()) + foreach (var file in apiConfig.EnumerateApiMarkdownFiles().Concat(apiConfig.Children)) { file.Refresh(); current[file.FullName] = file.LastWriteTimeUtc; From 670595285de4202a37efbe5be4fdb5f2baf82cc6 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 25 Aug 2026 14:05:07 +0200 Subject: [PATCH 13/13] Avoid case-only filename pairs in supplemental discovery tests Windows MockFileSystem collapses op-getAlertingHealth.md and op-getalertinghealth.md into one file. Keep ordinal matching and test casing with a single file. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .../Supplemental/ApiSupplementalDiscoveryTests.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs index 7372e4e835..64c5929da9 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs @@ -42,7 +42,6 @@ public void Discover_MatchesExactOperationId() var folder = FolderWith( "op-search.md", "op-getAlertingHealth.md", - "op-getalertinghealth.md", "op-cluster-health.md"); var result = ApiSupplementalDiscovery.Discover( @@ -51,7 +50,18 @@ public void Discover_MatchesExactOperationId() []); result.Operations.Keys.Should().BeEquivalentTo("search", "getAlertingHealth"); - result.Unmatched.Select(f => f.Name).Should().BeEquivalentTo("op-getalertinghealth.md", "op-cluster-health.md"); + result.Unmatched.Select(f => f.Name).Should().ContainSingle().Which.Should().Be("op-cluster-health.md"); + } + + [Fact] + public void Discover_DoesNotMatchDifferentCasing() + { + var folder = FolderWith("op-getalertinghealth.md"); + + var result = ApiSupplementalDiscovery.Discover(folder, ["getAlertingHealth"], []); + + result.Operations.Should().BeEmpty(); + result.Unmatched.Select(f => f.Name).Should().ContainSingle().Which.Should().Be("op-getalertinghealth.md"); } [Fact]